diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ead78eaa1d..6ff25fd904 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -2,7 +2,7 @@ ## Repository Overview -Entire CLI is a Go CLI tool that captures AI coding agent sessions (Claude Code, Codex, OpenCode, Cursor, Factory AI Droid, Copilot CLI, etc.) as searchable metadata stored separately from code commits. It uses a strategy pattern for session/checkpoint management with minimal impact on commit history. +Entire CLI is a Go CLI tool that captures AI coding agent sessions (Claude Code, Codex, Antigravity, OpenCode, Cursor, Factory AI Droid, Copilot CLI, etc.) as searchable metadata stored separately from code commits. It uses a strategy pattern for session/checkpoint management with minimal impact on commit history. **Tech Stack**: Go (version in `mise.toml`), Cobra (CLI), charmbracelet/huh (interactive prompts), go-git/v5 (with caveats) diff --git a/.github/workflows/e2e-windows.yml b/.github/workflows/e2e-windows.yml index 389a91fc6c..6d6921f88a 100644 --- a/.github/workflows/e2e-windows.yml +++ b/.github/workflows/e2e-windows.yml @@ -9,7 +9,7 @@ on: type: string default: "" agent: - description: "Agent to test (claude-code, factoryai-droid)" + description: "Agent to test (claude-code, factoryai-droid, antigravity)" required: false type: string default: claude-code @@ -27,6 +27,7 @@ on: options: - claude-code - factoryai-droid + - antigravity concurrency: group: e2e-windows-tests-${{ inputs.agent }} @@ -67,15 +68,28 @@ jobs: # droid.exe lands in %USERPROFILE%\bin, not .local\bin. "$env:USERPROFILE\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Install antigravity + if: inputs.agent == 'antigravity' + run: | + # agy's official installer: it resolves the architecture, verifies a + # SHA-512 from the release manifest, and installs into + # %LOCALAPPDATA%\agy\bin. It always takes latest and has no version + # flag — right for a dispatch-only workflow, which wants whatever agy + # users actually have. e2e.yml pins instead; see its own comment. + irm https://antigravity.google/cli/install.ps1 | iex + "$env:LOCALAPPDATA\agy\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Bootstrap agent shell: bash env: AGENT: ${{ inputs.agent }} # droid's runner writes ANTHROPIC_API_KEY into its BYOK settings, so # both agents bootstrap off the same key; FACTORY_API_KEY is droid's - # own auth. + # own auth. antigravity runs in agy's Gemini API-key mode off the + # shared GEMINI_API_KEY, as on Linux. ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} run: go run ./e2e/bootstrap "$AGENT" - name: Run isolated test @@ -85,6 +99,7 @@ jobs: AGENT: ${{ inputs.agent }} ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts E2E_PARALLEL: 1 run: | diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index cec4236212..c0bd878d5a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -11,6 +11,7 @@ on: - '' - claude-code - opencode + - antigravity - factoryai-droid - cursor-cli - copilot-cli @@ -52,7 +53,14 @@ jobs: if [ -n "$input" ]; then echo "agents=[\"$input\"]" >> "$GITHUB_OUTPUT" else - echo 'agents=["claude-code","opencode","factoryai-droid","cursor-cli","copilot-cli","roger-roger","codex"]' >> "$GITHUB_OUTPUT" + # antigravity runs in agy's Gemini API-key mode (the + # GEMINI_API_KEY repo secret, no account session). agy < 1.1.25 loaded + # .agents/hooks.json on that route but never executed the hooks + # (google-antigravity/antigravity-cli#893); the install step above + # always fetches the latest release, which has executed them since + # 1.1.25. With the optional ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON + # secret the leg runs on ADC instead. + echo 'agents=["claude-code","opencode","factoryai-droid","cursor-cli","copilot-cli","roger-roger","codex","antigravity"]' >> "$GITHUB_OUTPUT" fi e2e-tests: @@ -93,6 +101,32 @@ jobs: case "${{ matrix.agent }}" in claude-code) curl -fsSL https://claude.ai/install.sh | bash ;; opencode) curl -fsSL https://opencode.ai/install | bash ;; + antigravity) + # Deliberately NOT pinned, because pinning agy does not work. + # agy self-updates IN PLACE — the updater replaces the executable + # at its own path, which in CI is the file installed here — and + # it spawns that updater on essentially every launch, ~0.4s in, + # with auth not gating it. Its only brake is a 15-minute check + # keyed on state a fresh CI home never has. So a pinned tarball + # governs only the FIRST agy spawn of the job, and the harness + # spawns agy once per prompt (e2e/agents/antigravity.go), so + # everything after prompt one runs whatever the updater fetched. + # Measured on Windows 11 ARM64: 1.2.7 installed, one headless + # prompt, 1.2.9 at the same path afterwards. agy offers no way + # out — no flag, no settings key, no env var. + # + # The consequence, stated rather than implied: this job's agy + # version floats, so a third-party agy regression CAN redden PRs + # that never touched Antigravity (see #893). That exposure was + # always real; the pin that used to sit here only read like + # protection. Closing it needs the updater actually blocked — + # an unwritable install path, or no egress for this step — and + # neither is tested yet. + # + # nightly-e2e.yml also tracks latest, but for its own reason: + # that job exists to report agy drift. + curl -fsSL https://antigravity.google/cli/install.sh | bash + ;; codex) npm install -g @openai/codex ;; cursor-cli) curl https://cursor.com/install -fsS | bash ;; factoryai-droid) curl -fsSL https://app.factory.ai/cli | sh ;; @@ -109,6 +143,23 @@ jobs: command -v roger-roger command -v entire-agent-roger-roger + - name: Configure Antigravity ADC + if: matrix.agent == 'antigravity' + env: + ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON: ${{ secrets.ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON }} + run: | + set -euo pipefail + # ADC is optional since agy 1.1.13: without the secret the harness runs + # agy in Gemini API-key mode (the GEMINI_API_KEY repo secret). + if [ -z "${ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON:-}" ]; then + echo "No ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON secret; Antigravity E2E uses GEMINI_API_KEY (agy API-key mode)" + exit 0 + fi + creds="$RUNNER_TEMP/antigravity-google-credentials.json" + install -m 600 /dev/null "$creds" + printf '%s' "$ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON" > "$creds" + echo "GOOGLE_APPLICATION_CREDENTIALS=$creds" >> "$GITHUB_ENV" + - name: Bootstrap agent if: matrix.agent != 'roger-roger' env: @@ -162,9 +213,9 @@ jobs: retention-days: 7 e2e-windows: - # Windows covers the two agents e2e-windows.yml can install. An unset agent + # Windows covers the agents e2e-windows.yml can install. An unset agent # (the push path) means claude-code, as before. - if: inputs.agent == '' || inputs.agent == 'claude-code' || inputs.agent == 'factoryai-droid' + if: inputs.agent == '' || inputs.agent == 'claude-code' || inputs.agent == 'factoryai-droid' || inputs.agent == 'antigravity' uses: ./.github/workflows/e2e-windows.yml permissions: contents: read diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index 112691da3b..ce8a5d3eca 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -24,13 +24,13 @@ concurrency: jobs: smoke: runs-on: ${{ matrix.os }} - # Above the sum of the agents' own retry ceilings (~85m: 9 + 18 + 13.5 + - # 13.5 + 18 + 13.5, see the step caps below) plus setup, so the per-step cap - # is always the one that binds — a step timeout leaves an attributable row - # in the table, a job timeout kills Summarize and the artifact upload with - # it. Legs measure 6m30s-8m40s; this only binds on a hang in one of the - # uncapped setup steps. - timeout-minutes: 90 + # Above the sum of the agents' own retry ceilings (~108m: 9 + 18 + 13.5 + + # 13.5 + 18 + 13.5 + 22.5, see the step caps below) plus setup, so the + # per-step cap is always the one that binds — a step timeout leaves an + # attributable row in the table, a job timeout kills Summarize and the + # artifact upload with it. Legs measure 6m30s-8m40s; this only binds on a + # hang in one of the uncapped setup steps. + timeout-minutes: 115 permissions: contents: read actions: read @@ -181,32 +181,50 @@ jobs: # unauthenticated api.github.com call it gives no way to authenticate, # so it is rate-limited at random. npm install -g @openai/codex @github/copilot opencode-ai + # agy's official installer: it resolves OS and architecture itself, + # verifies a SHA-512 from the release manifest, and installs latest + # into ~/.local/bin, which the line below puts on PATH. + curl -fsSL https://antigravity.google/cli/install.sh | bash echo "$HOME/.local/bin" >> "$GITHUB_PATH" - echo "AGENTS=claude-code opencode codex cursor-cli factoryai-droid copilot-cli" >> "$GITHUB_ENV" + echo "AGENTS=claude-code opencode codex cursor-cli factoryai-droid copilot-cli antigravity" >> "$GITHUB_ENV" - # cursor-cli is absent: its E2E driver sends every prompt through tmux - # (e2e/agents/cursor_cli.go), because Cursor's headless -p mode fires no - # hooks — and there is no tmux on Windows. + # cursor-cli is the only absence here: its E2E driver sends every prompt + # through tmux (e2e/agents/cursor_cli.go), because Cursor's headless -p + # mode fires no hooks — and there is no tmux on Windows. + # + # antigravity matters most on this leg: it is the one agent whose Windows + # hook path differs from its Unix one (agy hands hook commands to + # cmd.exe, not sh), and a hook that fails there is silent — agy still + # reports success. Nothing else exercises that path automatically. - name: Install agent CLIs (Windows) if: matrix.os == 'windows-latest' shell: pwsh run: | irm https://claude.ai/install.ps1 | iex irm https://app.factory.ai/cli/windows | iex + irm https://antigravity.google/cli/install.ps1 | iex npm install -g @openai/codex @github/copilot opencode-ai # Stop does not cover native commands ($PSNativeCommandUseError- # ActionPreference defaults to $false), so npm's failure needs this. if ($LASTEXITCODE -ne 0) { throw "npm install failed (exit $LASTEXITCODE)" } - # Neither iex line needs such a guard: both installers throw or exit - # on every failure path, and that ends this step. Verified 2026-09-09 - # for claude, 2026-09-10 for droid, whose installer shadows - # Write-Error with one that exits 1. + # None of the three iex lines needs such a guard: each installer + # throws or exits on every failure path, and that ends this step. + # Verified 2026-09-09 for claude, 2026-09-10 for droid, whose + # installer shadows Write-Error with one that exits 1, and + # 2026-09-23 for agy, which throws rather than exits when it is run + # sourced, as `| iex` runs it. "$HOME\.local\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append # droid's Windows installer puts droid.exe in %USERPROFILE%\bin, not # .local\bin, and updates only the stored user PATH, which later # steps do not re-read. "$HOME\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - "AGENTS=claude-code codex copilot-cli opencode factoryai-droid" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + # agy lands in %LOCALAPPDATA%\agy\bin, and its installer updates the + # stored user PATH the same way. It picks the architecture itself and + # always serves latest, so there is nothing to keep in sync here; + # e2e.yml downloads a release tarball instead only because it pins an + # exact version, which this installer has no flag for. + "$env:LOCALAPPDATA\agy\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + "AGENTS=claude-code codex copilot-cli opencode factoryai-droid antigravity" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # One step per agent: a step timeout is the only per-agent bound that # works on all three runners (macOS has no timeout(1)), and one hung agent @@ -233,7 +251,8 @@ jobs: # E2E_ENTIRE_BIN (job env, set above) is what makes this the *installed* # nightly: the mise task skips its build when it is set. # - # The six bodies are byte-identical; only AGENT and the key differ. + # The seven bodies are byte-identical; only AGENT, the key and (for + # antigravity, whose TimeoutMultiplier is 2.5x) the step cap differ. - name: Smoke test claude-code if: ${{ !cancelled() && contains(format(' {0} ', env.AGENTS), ' claude-code ') }} timeout-minutes: 20 @@ -351,6 +370,28 @@ jobs: echo "$AGENT $rc" >> e2e/artifacts/results.txt exit "$rc" + # 25 minutes: 3 attempts x 3 min x the 2.5x multiplier is 22.5 min. + # GEMINI_API_KEY selects agy's API-key mode (no account session); the + # harness writes modelProvider=gemini into an isolated HOME itself. + - name: Smoke test antigravity + if: ${{ !cancelled() && contains(format(' {0} ', env.AGENTS), ' antigravity ') }} + timeout-minutes: 25 + shell: bash + env: + AGENT: antigravity + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + set +e + set -uo pipefail + mkdir -p e2e/artifacts + go run ./e2e/bootstrap "$AGENT" && + E2E_ARTIFACT_DIR="$ARTIFACT_ROOT/$AGENT" \ + mise run test:e2e --agent "$AGENT" "$TEST_FILTER" + rc=$? + grep -q '^Total: 0 ' "$ARTIFACT_ROOT/$AGENT/report.txt" 2>/dev/null && rc=1 + echo "$AGENT $rc" >> e2e/artifacts/results.txt + exit "$rc" + - name: Summarize if: always() shell: bash diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 512dda2739..d404dad50d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -213,7 +213,7 @@ These are markdown files that define specialized behaviors for Claude Code (e.g. ### 2. Coding Agent Integrations (Go) -These are Go implementations that integrate Entire with different AI coding tools (Claude Code, Codex, OpenCode, Cursor, Factory AI Droid, Copilot CLI, etc.) using the Agent abstraction layer. +These are Go implementations that integrate Entire with different AI coding tools (Claude Code, Codex, Antigravity, OpenCode, Cursor, Factory AI Droid, Copilot CLI, etc.) using the Agent abstraction layer. - **Location:** `cmd/entire/cli/agent/` - **Steps:** @@ -272,7 +272,7 @@ Addressing Copilot feedback upfront is the fastest path to maintainer review. Entire exists to help you work with AI coding agents, so it would be odd if you weren't using one to contribute. There's no need to tell us you did. Our general thinking: use whatever agent and methodology you like, but until the robot revolution comes, you are responsible for the final code. Before submitting a PR for review, make sure you have reviewed it yourself. We'll close PRs that obviously skipped this step. -Entire supports Claude Code, Codex, OpenCode, Cursor, Factory AI Droid, Copilot CLI, and Pi, so feel free to use whichever one you're most comfortable with. +Entire supports Claude Code, Codex, Antigravity, OpenCode, Cursor, Factory AI Droid, Copilot CLI, and Pi, so feel free to use whichever one you're most comfortable with. One thing to watch out for is LLM eagerness. Agents like to please and they're in a hurry. A few common failure modes to push back on: diff --git a/README.md b/README.md index 25d650065a..9609e2ab9d 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ With Entire, you can: - **Understand why code changed, not just what** — Transcripts, prompts, files touched, token usage, tool calls, and more are captured alongside every commit. - **Resume from any checkpoint** — Go back to any previous agent session and pick up exactly where you or a coworker left off. - **Full context preserved and searchable** — A versioned record of every AI interaction tied to your git history, with nothing lost. -- **Zero context switching** — Git-native, two-step setup, works with Claude Code, Codex, Cursor, Pi, and more. +- **Zero context switching** — Git-native, two-step setup, works with Claude Code, Codex, Cursor, Antigravity, Pi, and more. ## Table of Contents @@ -377,7 +377,7 @@ These are visible in developer and nightly builds and hidden in stable releases, | Flag | Description | | ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `--agent ` | Agent to set up hooks for: `claude-code`, `codex`, `copilot-cli`, `cursor`, `factoryai-droid`, `opencode`, `pi` (external agents on `$PATH` also work). Enables non-interactive mode | +| `--agent ` | Agent to set up hooks for: `antigravity`, `claude-code`, `codex`, `copilot-cli`, `cursor`, `factoryai-droid`, `opencode`, `pi` (external agents on `$PATH` also work). Enables non-interactive mode | | `--yes`, `-y` | Accept all defaults without prompting | | `--force`, `-f` | Force reinstall hooks (removes existing Entire hooks first) | | `--checkpoint-remote ` | Push checkpoint data to a separate repo; providers `github`, `gitlab` (e.g., `github:org/checkpoints-repo`) | @@ -526,6 +526,7 @@ Each agent stores its hook configuration in its own directory. When you run `ent | Agent | Hook Location | Format | | ---------------- | ----------------------------- | ----------------- | +| Antigravity | `.agents/hooks.json` | JSON hooks config | | Claude Code | `.claude/settings.json` | JSON hooks config | | Codex | `.codex/hooks.json` | JSON hooks config | | Copilot CLI | `.github/hooks/entire.json` | JSON hooks config | @@ -616,7 +617,7 @@ When enabled, Entire automatically generates AI summaries for checkpoints at com Summaries are also generated on demand, with or without this setting, by `entire checkpoint explain --generate`. -**Which agent writes them.** By default Claude Code (`claude` on your `PATH`, model `sonnet`). Set a different one with `summary_generation.provider` — `claude-code`, `codex`, `copilot-cli`, `cursor`, `opencode`, or `pi`, plus an optional `summary_generation.model` hint: +**Which agent writes them.** By default Claude Code (`claude` on your `PATH`, model `sonnet`). Set a different one with `summary_generation.provider` — `antigravity`, `claude-code`, `codex`, `copilot-cli`, `cursor`, `opencode`, or `pi`, plus an optional `summary_generation.model` hint: ```bash entire configure --summarize-provider codex diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index cbde85c6c5..687a06a4f9 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -5,6 +5,7 @@ package agent import ( "context" + "encoding/json" "io" "os/exec" "time" @@ -217,7 +218,7 @@ type TranscriptAnalyzer interface { // - files: list of file paths modified by the agent (from Write/Edit tools) // - currentPosition: the current position (line count or message count) // - error: any error encountered during reading - ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) + ExtractModifiedFilesFromOffset(ctx context.Context, path string, startOffset int) (files []string, currentPosition int, err error) } // PromptExtractor extracts user prompts from a transcript file. @@ -230,6 +231,21 @@ type PromptExtractor interface { ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) } +// TranscriptPromptExtractor extracts user prompts from transcript CONTENT the +// caller already holds. Condensation reads the transcript once — from the live +// path, or from the shadow-branch copy when the live path cannot be read — and +// stores those bytes in the checkpoint; the prompts it records must come from +// the same bytes, not from a second read of the path that can see a different +// (missing, shorter, or later) file. Optional: agents that only implement +// PromptExtractor keep the path-based fallback. +type TranscriptPromptExtractor interface { + Agent + + // ExtractPromptsFromTranscript returns user prompts from content starting + // at the given offset, using the same offset metric as ExtractPrompts. + ExtractPromptsFromTranscript(content []byte, fromOffset int) ([]string, error) +} + // TranscriptPreparer is called before ReadTranscript to handle agent-specific // flush/sync requirements (e.g., Claude Code's async transcript writing). // The framework calls PrepareTranscript before ReadTranscript if implemented. @@ -241,6 +257,35 @@ type TranscriptPreparer interface { PrepareTranscript(ctx context.Context, sessionRef string) error } +// LateTranscriptWriter marks agents whose transcript file is written only +// AFTER the Stop hook rather than streamed during the turn (e.g. Antigravity). +// Implementing this interface is the trait signal the strategy layer keys off +// instead of hardcoding agent types: mid-turn, such an agent's on-disk +// transcript can only contain previous turns' content, so an empty live +// transcript at condensation is a legitimate state (degrade, don't error) and +// transcript positions recorded at Stop may lag when the flush loses the race. +type LateTranscriptWriter interface { + Agent + + // CountTranscriptPosition returns the checkpoint-offset position for raw + // transcript content, using the same counting rule as the agent's readers + // (GetTranscriptPosition, ExtractPrompts). The value is stored in + // CheckpointTranscriptStart and later fed back to those readers as an + // offset — writer and readers must agree on the metric or an interior + // format quirk (e.g. a blank line) silently shifts extraction for the + // next checkpoint. + CountTranscriptPosition(content []byte) int + + // SliceTranscriptFromPosition returns content scoped to the lines after + // startOffset, counted by the same rule CountTranscriptPosition uses. + // Consumers that scope a transcript to one checkpoint range must go + // through this rather than a generic line slicer: startOffset was + // produced by the agent's metric, and re-deriving it under another rule + // reintroduces exactly the drift CountTranscriptPosition exists to stop. + // Returns nil when nothing follows startOffset. + SliceTranscriptFromPosition(content []byte, startOffset int) []byte +} + // TranscriptFetcher is implemented by agents that can materialize a session // transcript on demand (e.g. OpenCode via `opencode export`), including for // sessions Entire never tracked — where no hook-cached transcript file exists @@ -301,6 +346,31 @@ type TokenCalculator interface { CalculateTokenUsage(transcriptData []byte, fromOffset int) (*TokenUsage, error) } +// OutOfBandTokenSource provides token usage from a source other than the +// transcript. Antigravity is the only agent that needs this: agy never writes +// token data into its transcript or hook payloads — its title/statusline pipe +// is the only surface, captured to disk by `entire hooks antigravity +// title-tee` (see agent/antigravity/statusline.go). +// +// Flow: the lifecycle calls SnapshotTokenBaseline at TurnStart and stores the +// opaque baseline in PrePromptState; at TurnEnd (when transcript-based +// calculation yields nothing) it calls CalculateTokenUsageSince to get the +// checkpoint-scoped delta — the same cumulative-totals-minus-baseline pattern +// Codex uses, sourced out-of-band. +type OutOfBandTokenSource interface { + Agent + + // SnapshotTokenBaseline returns an opaque, agent-defined marker of the + // current cumulative token position for the session. A nil baseline with + // nil error means "no usage observed yet" (delta will count from zero). + SnapshotTokenBaseline(ctx context.Context, sessionID string) (json.RawMessage, error) + + // CalculateTokenUsageSince computes usage between the baseline and now. + // A nil result with nil error means no data is available (degrade to no + // token counts, never to an error). + CalculateTokenUsageSince(ctx context.Context, sessionID string, baseline json.RawMessage) (*TokenUsage, error) +} + // SubagentReference is the authoritative record of one spawned agent supplied // by the session ledger. Transcript paths are hints only: implementations must // verify that a path's native metadata identifies this exact AgentID. diff --git a/cmd/entire/cli/agent/antigravity/AGENT.md b/cmd/entire/cli/agent/antigravity/AGENT.md new file mode 100644 index 0000000000..6bd6f94dcd --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/AGENT.md @@ -0,0 +1,226 @@ +# Antigravity CLI (`agy`) — Integration One-Pager + +## Verdict: COMPATIBLE (Preview) + +The `agy` binary (Antigravity 2.0, Google's Gemini CLI successor) supports +workspace-scoped hooks via `.agents/hooks.json` and writes JSONL transcripts to +a predictable per-conversation location. The integration is **Preview** +status: documented here and in `docs/architecture/agent-guide.md`, with the +known limitations listed below (the CLI no longer renders a preview label +anywhere — `agent.IsPreview` was removed in #2554). Wire format captured on +agy **1.0.14/1.0.15** (real captured stdin, not docs) and re-verified +unchanged against agy **1.1.1** (2026-07-13); agy is fast-moving. + +**Key differences from other agents:** hooks fire per *model invocation*, not +per user prompt; the transcript is written **after** the Stop hook; token usage +appears **only** in the title/statusline JSON feed (never in transcripts or +hook payloads); tool args can arrive double-encoded. + +## Binary + +- Install: `curl`-based installer / GitHub releases (`google-antigravity/antigravity-cli`). +- Headless: `agy -p "" --add-dir ` (without + `--add-dir`, agy runs in `~/.gemini/antigravity-cli/scratch/`, not the cwd). + The path MUST be absolute: a relative one (`.`) is rejected but does not fail + the run — agy logs `failed to resolve --add-dir path` and `loaded 0 named + hooks`, then runs the turn with no hooks and exit 0, which looks exactly like + a hook-loading bug. +- Resume: `agy --conversation ` (used by `FormatResumeCommand`). +- Auth: consumer OAuth, ADC + `--project`, or (agy ≥ 1.1.13) **Gemini API-key + mode**: `{"modelProvider":"gemini"}` in `~/.gemini/antigravity-cli/settings.json` + plus `GEMINI_API_KEY` in the environment — no account session, keyring or + browser flow; startup fails fast if the key is unset. Optional + `GOOGLE_GEMINI_BASE_URL`. agy prefers `GOOGLE_API_KEY` when both keys are set. + The default (`cloudcode-pa.googleapis.com`) backend remains entitlement-gated; + see `e2e/README.md`. +- Version notes (1.1.1 → 1.2.7): hook surface unchanged (5 hook types); + 1.1.25 started executing hooks in Gemini API-key mode (see Known + limitations); 1.2.x dropped the Gemini 3.5 models from `agy models`; + 1.1.10 fixed hook ordering so `Stop`/`PostInvocation` fire reliably; + 1.1.12 answers `-p "/hooks"` (and `/help`, `/changelog`) locally without a + model turn on the platforms where that was checked — but agy 1.2.7 on + Windows 11 ran a full model turn for it, so `entire doctor` runs the + `agy -p /hooks --add-dir --output-format json` probe only with + `ENTIRE_ANTIGRAVITY_DOCTOR_PROBE=1`; by default it checks, at zero cost, + that the installed hook command is the shape this host needs; + 1.1.8/1.1.20 added `--output-format json|stream-json` and made headless exit + codes reflect only run-level failures; 1.1.9 expands `/skill` in `-p`. + +## Hook Mechanism + +Hooks live in the workspace at `.agents/hooks.json` under a named key (ours is +`"entire"`). Tool events (`PreToolUse`/`PostToolUse`) use `matcher` + `hooks` +lists; `PreInvocation`/`PostInvocation`/`Stop` use flat handler lists. Install +replaces the whole `"entire"` entry (idempotent by compact-JSON comparison), so +stale installs self-heal on the next `entire enable`. + +### Hook Names and Event Mapping + +Only the three hooks with lifecycle meaning are installed. agy's +`PostToolUse`/`PostInvocation` are deliberately **not** installed — no +lifecycle mapping, and each would spawn a no-op `entire` subprocess per tool +call / model invocation. Unknown verbs parse to a nil event, so a stale +hooks.json can never fail an agy turn. + +| agy hook | Fires | Entire event | +|----------|-------|--------------| +| `PreInvocation` | before **every model invocation** (`invocationNum` is **0-indexed**) | `TurnStart` when `invocationNum == 0`; for `> 0`, a `TurnStart` with `Event.SuppressIfSessionActive` — the dispatcher drops it only when a turn is genuinely mid-flight (`Phase == ACTIVE` and not stuck), so resumes (`agy --conversation`) are tracked while follow-up invocations don't clobber the pre-prompt baseline | +| `PreToolUse` (matcher `*`) | before each tool call | `ToolUse` for mutating tools (`write_to_file`, `replace_file_content`, `multi_replace_file_content`) → `FilesTouched` | +| `Stop` | at agent stop; payload carries `fullyIdle` | `TurnEnd` when `fullyIdle == true`; nil otherwise (background tasks still running) | + +There is **no SessionStart surface** — no way to print a "tracked by entire" +banner inside agy (silent tracking, same as Cursor/OpenCode/Copilot/Pi). + +### Hook Input Payloads (Captured) + +Common fields consumed: `conversationId`, `transcriptPath`. agy also sends +`workspacePaths`, `artifactDirectoryPath`, `stepIdx`, `initialNumSteps`, +`executionNum`, `terminationReason`, `error` — tolerated but not decoded (add +fields only when something reads them). Captured fixtures in `testdata/`. + +- `PreInvocation`: `invocationNum` (0-indexed; the docs now state this + explicitly). `initialNumSteps` is unusable as a "first?" signal — agy inserts + the user prompt as step 0, so it is already 1 on the first invocation. +- `PreToolUse`: `toolCall.name`, `toolCall.args`. **Args can be + double-encoded** (`"TargetFile":"\"foo.txt\""`, `"Overwrite":"true"`); + `decodeAgyString`/`decodeAgyBool` accept both the documented and the wire + shape. Paths are symlink-resolved (`resolveAgySymlinks`) so macOS + `/tmp → /private/tmp` doesn't defeat repo-relative filtering. +- `Stop`: `fullyIdle` (required). + +## Transcript + +Written to +`~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript_full.jsonl` +(the hook payload's `transcriptPath` points there; agy also writes a truncated +`transcript.jsonl` alongside). Schema: one step object per line — +`step_index`, `source` (`USER_EXPLICIT`/`MODEL`/`SYSTEM`), `type` +(`USER_INPUT`/`PLANNER_RESPONSE`/`CODE_ACTION`/...), `content`, `tool_calls`. + +**agy writes the transcript AFTER the Stop hook** (sometimes seconds later). +Consequences, all handled: + +- `PrepareTranscript` (TranscriptPreparer) briefly waits, then materialises an + empty placeholder so the framework's fileExists check doesn't abort the turn. +- Prompts are re-extracted at condensation via the late-flush fallback + (`resolvePromptsFromLateFlushedTranscript`) when `prompt.txt` is empty. +- A first-turn mid-turn commit condenses against the empty placeholder as a + **files/prompt-only checkpoint** (degrade is agy-scoped; other agents keep + the error/retry invariant). + +### TranscriptAnalyzer + +`ExtractPrompts` (USER_INPUT steps, `` unwrap), +`GetTranscriptPosition`, and `ExtractModifiedFilesFromOffset` share one +offset metric: non-blank JSONL lines, blank lines skipped **before** counting +(`forEachNonBlankLine` is the single owner — the position one method stores is +consumed by the others). Validated against 385 real captured transcripts +(0 errors, 0 offset mismatches). + +## Token Usage (out-of-band) + +agy never writes token data into transcripts or hook payloads. The **only** +surface is the state JSON piped to the global `statusLine`/`title` command +slots (`context_window`: `total_input_tokens`, `total_output_tokens`, +`current_usage.*`). The integration: + +- claims the lower-stakes **`title` slot** in agy's global + `~/.gemini/antigravity-cli/settings.json` with + `entire hooks antigravity title-tee` (wrapping and preserving any + pre-existing user title command via `--wrap ''` where agy runs + the slot through sh, or `--wrap-b64 ` on Windows, where agy + runs it through cmd.exe and POSIX quoting would be torn apart; the tee + re-runs the original through the same shell agy would have used); +- the tee appends deduped snapshots to a per-conversation JSONL cache; +- `OutOfBandTokenSource`: `SnapshotTokenBaseline` at TurnStart (streaming + last-line read), `CalculateTokenUsageSince` at TurnEnd (cumulative totals + minus baseline; cache fields from snapshot lines strictly after the + baseline timestamp); +- the delta is recorded even when a turn ends with everything already + committed (agy's normal flow), so no tokens are lost; +- checkpoint metadata takes the checkpoint-scoped accumulator + (`state.CheckpointTokenUsage`), never the session-cumulative total. + +`entire doctor` warns when hooks are installed but the title slot doesn't +route through the tee; `entire agent remove antigravity` restores/removes the +slot (matching the bare tee **by shape**, so uninstalling from a different +worktree than the install still cleans up, including the legacy local-dev +`go run …` form older versions wrote). + +## Config Preservation + +- `.agents/hooks.json`: foreign keys preserved; only the `"entire"` entry is + managed. `HookConfig` keeps the `PostToolUse`/`PostInvocation`/`enabled` + schema fields for round-trip fidelity and stale-install detection. +- Global `settings.json`: only the `title` key is touched; user title commands + are wrapped, restored on uninstall, and anything unrecognized is left alone. + +## Gaps & Limitations (Preview) + +- **Silent tracking** in the agy UI (no SessionStart surface); `entire status` + is the visibility surface. +- **Headless subagent runs leave a ghost parent session**: agy runs subagents + as separate conversations with their own hooks; in `-p` mode the parent + conversation only receives `fullyIdle=false` Stops before the process exits, + so its session stays ACTIVE (visible in `entire status` until the stale + threshold). The subagent's work condenses normally, and ghost sessions with + no tracked files no longer pin shadow branches. +- **Mid-turn commit token scoping is coarse**: such checkpoints record zero + tokens; the turn's delta lands on the next condensation (session totals stay + correct). +- **First-turn mid-turn commits** may produce checkpoints without transcript + content (see Transcript above). +- **Token capture depends on the title slot** staying routed through the tee + (doctor-checked, setup-repaired). +- **Gemini API-key mode needs agy ≥ 1.1.25 for hooks.** Through 1.1.24 agy + loaded `.agents/hooks.json` on that auth route but never executed the hooks + (on OAuth/ADC the same hooks fired; reported as + google-antigravity/antigravity-cli#893, still open). Bisecting the release + binaries with a probe hook shows 1.1.25 and every later release execute them, + so the e2e harness's API-key mode (CI installs the latest agy) produces + checkpoints and the leg runs in the default matrix. The default + `cloudcode-pa` backend stays entitlement-gated (AUTH_PERMISSION_DENIED, + subject 110002 without a Gemini Code Assist subscription); the harness fails + fast on those walls and on `GEMINI_API_KEY … not set` / `API_KEY_INVALID`. + Details: `e2e/README.md`. +- **Transcript-derived file lists can be incomplete**: agy sometimes persists a + `PLANNER_RESPONSE` step with `truncated_fields`, dropping `TargetFile` from a + mutating tool call (~0.8% of `replace_file_content` calls in one corpus). + `ExtractModifiedFilesFromOffset` logs a WARN per dropped call instead of + silently skipping; live PreToolUse stdin is never truncated, so normal turns + are unaffected — only the late-flush / first-turn mid-turn fallbacks are. +- **Headless prompt goes in argv**: agy ≥ 1.2.x ignores stdin in print mode + (`-p " "` → "Error: empty prompt"; `-p -` is answered as the literal message + "-"), so `GenerateText` (summaries, `dispatch --local`) passes the prompt as + the `-p` value. The summarizer condenses agy step JSONL through + `antigravity.CondenseTranscript` (USER_INPUT, PLANNER_RESPONSE text and tool + calls; GENERIC/SYSTEM_MESSAGE skipped) — without it agy transcripts fell + through to the Claude parser and summarized to nothing. +- **First-run onboarding in a fresh HOME**: interactive `agy` shows a + color-scheme chooser and a Terms of Service & Data Use consent before the + prompt (Enter on the consent only toggles its checkbox; Down, Right, Enter + reaches [Done]). Headless `-p` runs skip both. State lives in + `~/.gemini/antigravity-cli/cache/onboarding.json` + (`{"onboardingComplete":true,"consumerOnboardingComplete":true,...}`), which + the e2e harness seeds into its isolated HOMEs. +- **Untrusted-workspace trap**: `agy -p` in a folder agy doesn't trust resolves + cwd to `~/.gemini/antigravity-cli/scratch/` — no hooks fire, no session, exit + 0. Workspace hooks (`.agents/hooks.json`) only load for a **trusted** + workspace (`trustedWorkspaces` array of absolute paths in agy's global + `settings.json`; interactive prompt "Do you trust the contents of this + project?"). Always pass `--add-dir ` (a relative + path is silently dropped, see Binary above) and, in a fresh HOME, pre-seed + `trustedWorkspaces` (the e2e harness does both). +- **Review**: agy is eligible in the `entire review` skill picker (skill + discovery across `~/.gemini/config/skills` (agy 1.1+ global), + `~/.gemini/antigravity-cli/skills`, `~/.gemini/skills`, + `/.agents/skills` (agy 1.1+ workspace), and legacy + `/.agent/skills`; `/name` invocation form) but is not a launchable + reviewer. + +## Captured Payloads + +`testdata/hook_stdin_pre_invocation.json`, `testdata/hook_stdin_pre_tool_use.json`, +`testdata/hook_stdin_stop.json` — real agy 1.0.x stdin captures (fixtures carry +the full documented payloads to pin unknown-field tolerance); +`testdata/transcript_sample.jsonl` — captured transcript fixture. diff --git a/cmd/entire/cli/agent/antigravity/antigravity.go b/cmd/entire/cli/agent/antigravity/antigravity.go new file mode 100644 index 0000000000..2a6e41dbbb --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/antigravity.go @@ -0,0 +1,104 @@ +// Package antigravity implements the Agent interface for Antigravity (Google's agentic coding CLI). +package antigravity + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" +) + +const antigravityTestBrainDirEnv = "ENTIRE_TEST_ANTIGRAVITY_BRAIN_DIR" + +//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 +} + +var _ agent.OutOfBandTokenSource = (*AntigravityAgent)(nil) + +// 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)" +} + +// 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) +} + +func (a *AntigravityAgent) ProtectedDirs() []string { return []string{".agents", ".gemini"} } + +// --- Legacy methods --- + +func (a *AntigravityAgent) GetSessionID(input *agent.HookInput) string { return input.SessionID } +func (a *AntigravityAgent) GetSessionDir(_ string) (string, error) { + if override := os.Getenv(antigravityTestBrainDirEnv); override != "" { + return override, nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("antigravity: failed to get home directory: %w", err) + } + return filepath.Join(homeDir, ".gemini", "antigravity-cli", "brain"), nil +} + +func (a *AntigravityAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + return filepath.Join(sessionDir, agentSessionID, ".system_generated", "logs", "transcript_full.jsonl") +} + +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, session *agent.AgentSession) error { + if session == nil { + return errors.New("antigravity: session is nil") + } + if session.AgentName != "" && session.AgentName != a.Name() { + return fmt.Errorf("antigravity: session belongs to agent %q, not %q", session.AgentName, a.Name()) + } + if session.SessionRef == "" { + return errors.New("antigravity: session reference is required") + } + if len(session.NativeData) == 0 { + return errors.New("antigravity: session has no native data to write") + } + // Through the agent's session store, like every other agent: the write is + // contained to agy's brain directory and never follows a symlink. + if err := agent.WriteSessionFile(a, session, session.NativeData, 0o600); err != nil { + return fmt.Errorf("antigravity: write transcript: %w", err) + } + return nil +} + +// HookConfigRelPath implements agent.HookConfigLocator: agy loads workspace +// hooks from .agents/hooks.json at the worktree root. +func (a *AntigravityAgent) HookConfigRelPath() string { + return ".agents/" + AgentsHooksFileName +} + +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..c4f97559c7 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/antigravity_test.go @@ -0,0 +1,163 @@ +package antigravity + +import ( + "context" + "os" + "path/filepath" + "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) + t.Setenv(configDirEnv, t.TempDir()) + + ag := &AntigravityAgent{} + if _, err := ag.InstallHooks(context.Background(), 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") + } + }) +} + +func TestGetSessionDir_HonorsTestOverride(t *testing.T) { + override := filepath.Join(t.TempDir(), "brain") + t.Setenv("ENTIRE_TEST_ANTIGRAVITY_BRAIN_DIR", override) + + got, err := (&AntigravityAgent{}).GetSessionDir("/repo") + if err != nil { + t.Fatalf("GetSessionDir() error = %v", err) + } + if got != override { + t.Fatalf("GetSessionDir() = %q, want %q", got, override) + } +} + +func TestResolveSessionFile_UsesAntigravityBrainTranscriptPath(t *testing.T) { + t.Parallel() + + got := (&AntigravityAgent{}).ResolveSessionFile("/home/me/.gemini/antigravity-cli/brain", "conv-123") + want := filepath.Join( + "/home/me/.gemini/antigravity-cli/brain", + "conv-123", + ".system_generated", + "logs", + "transcript_full.jsonl", + ) + if got != want { + t.Fatalf("ResolveSessionFile() = %q, want %q", got, want) + } +} + +func TestWriteSession_WritesTranscriptData(t *testing.T) { + t.Parallel() + + path := filepath.Join( + t.TempDir(), + "brain", + "conv-123", + ".system_generated", + "logs", + "transcript_full.jsonl", + ) + data := []byte(`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE"}` + "\n") + err := (&AntigravityAgent{}).WriteSession(context.Background(), &agent.AgentSession{ + SessionID: "conv-123", + AgentName: agent.AgentNameAntigravity, + SessionRef: path, + NativeData: data, + }) + if err != nil { + t.Fatalf("WriteSession() error = %v", err) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read restored transcript: %v", err) + } + if string(got) != string(data) { + t.Fatalf("restored transcript = %q, want %q", got, data) + } +} + +func TestWriteSession_RejectsInvalidInput(t *testing.T) { + t.Parallel() + + ag := &AntigravityAgent{} + validPath := filepath.Join(t.TempDir(), "transcript_full.jsonl") + cases := []struct { + name string + session *agent.AgentSession + }{ + {name: "nil session", session: nil}, + {name: "wrong agent", session: &agent.AgentSession{ + AgentName: agent.AgentNameClaudeCode, + SessionRef: validPath, + NativeData: []byte("{}\n"), + }}, + {name: "empty ref", session: &agent.AgentSession{ + AgentName: agent.AgentNameAntigravity, + NativeData: []byte("{}\n"), + }}, + {name: "empty data", session: &agent.AgentSession{ + AgentName: agent.AgentNameAntigravity, + SessionRef: validPath, + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if err := ag.WriteSession(context.Background(), tc.session); err == nil { + t.Fatal("WriteSession() error = nil, want error") + } + }) + } +} diff --git a/cmd/entire/cli/agent/antigravity/discovery.go b/cmd/entire/cli/agent/antigravity/discovery.go new file mode 100644 index 0000000000..33cdf7535f --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/discovery.go @@ -0,0 +1,61 @@ +package antigravity + +import ( + "context" + "log/slog" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/skilldiscovery" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// DiscoverReviewSkills walks Antigravity's three user-skill scopes looking for +// review-adjacent skills. agy stores skills as //SKILL.md with +// YAML frontmatter (name + description); the model activates them by +// description, and they are referenced with a "/name" slash invocation. +// +// Scopes, in precedence order (global wins on name collision): +// - global (agy 1.1+): ~/.gemini/config/skills +// - global (pre-1.1 layouts): ~/.gemini/antigravity-cli/skills, ~/.gemini/skills +// - workspace (agy 1.1+): /.agents/skills +// - workspace (legacy, honored): /.agent/skills +// +// agy 1.1 moved the defaults ("Antigravity now defaults to .agents/skills, +// but still maintains backward support for .agent/skills"; global discovery +// under ~/.gemini/config/) — all roots are scanned so skills keep resolving +// across agy versions. +// +// Google's built-in skills under ~/.gemini/antigravity-cli/builtin/skills are +// deliberately NOT scanned — they are shipped guides (e.g. antigravity-guide), +// not user review tools. +// +// Best-effort per the SkillDiscoverer contract: unreadable HOME or missing +// directories yield (nil, nil); malformed SKILL.md files are skipped by the +// shared scanner with a Debug log. +// +//nolint:unparam // error return is part of the SkillDiscoverer contract +func (a *AntigravityAgent) DiscoverReviewSkills(ctx context.Context) ([]agent.DiscoveredSkill, error) { + home, err := os.UserHomeDir() + if err != nil { + logging.Debug(ctx, "antigravity discovery: UserHomeDir failed", slog.String("error", err.Error())) + return nil, nil + } + + var found []agent.DiscoveredSkill + found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(home, ".gemini", "config", "skills"), "", skilldiscovery.SlashForm)...) + found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(home, ".gemini", "antigravity-cli", "skills"), "", skilldiscovery.SlashForm)...) + found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(home, ".gemini", "skills"), "", skilldiscovery.SlashForm)...) + if root, rootErr := paths.WorktreeRoot(ctx); rootErr == nil { + found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(root, ".agents", "skills"), "", skilldiscovery.SlashForm)...) + found = append(found, skilldiscovery.ScanSkillsDir(ctx, filepath.Join(root, ".agent", "skills"), "", skilldiscovery.SlashForm)...) + } + + found = skilldiscovery.DedupeByInvocation(found) + if len(found) == 0 { + return nil, nil + } + return found, nil +} diff --git a/cmd/entire/cli/agent/antigravity/discovery_test.go b/cmd/entire/cli/agent/antigravity/discovery_test.go new file mode 100644 index 0000000000..d3419785a3 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/discovery_test.go @@ -0,0 +1,120 @@ +package antigravity + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/go-git/go-git/v6" +) + +func writeAgySkill(t *testing.T, scopeDir, name, description string) { + t.Helper() + dir := filepath.Join(scopeDir, name) + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + body := "---\nname: " + name + "\ndescription: " + description + "\n---\nbody\n" + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o600); err != nil { + t.Fatalf("write: %v", err) + } +} + +func TestDiscoverReviewSkills_ScansGlobalAndSharedScopes(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Chdir(t.TempDir()) // non-repo cwd → project scope skipped deterministically + + writeAgySkill(t, filepath.Join(home, ".gemini", "antigravity-cli", "skills"), "code-review", "Review PRs.") + writeAgySkill(t, filepath.Join(home, ".gemini", "skills"), "security-audit", "Audit deps.") + writeAgySkill(t, filepath.Join(home, ".gemini", "skills"), "formatter", "Format code.") + + got, err := (&AntigravityAgent{}).DiscoverReviewSkills(context.Background()) + if err != nil { + t.Fatalf("DiscoverReviewSkills: %v", err) + } + names := map[string]bool{} + for _, s := range got { + names[s.Name] = true + } + if !names["/code-review"] { + t.Errorf("missing global-scope /code-review: %+v", got) + } + if !names["/security-audit"] { + t.Errorf("missing shared-scope /security-audit: %+v", got) + } + if names["/formatter"] { + t.Errorf("non-review /formatter should be excluded: %+v", got) + } +} + +func TestDiscoverReviewSkills_ScansAgy11DefaultRoots(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + repo := t.TempDir() + // Bare git init is enough — DiscoverReviewSkills only needs WorktreeRoot + // to resolve; no commits or repo-local config are involved. (testutil is + // architecturally off-limits to agent packages; see architecture_test.go.) + if _, err := git.PlainInit(repo, false); err != nil { + t.Fatalf("git init: %v", err) + } + t.Chdir(repo) + paths.ClearWorktreeRootCache() + t.Cleanup(paths.ClearWorktreeRootCache) + + // agy 1.1.x moved the default scopes: global skills live under + // ~/.gemini/config/skills and workspace skills under .agents/skills + // (legacy .agent/skills is still honored for backward compatibility). + writeAgySkill(t, filepath.Join(home, ".gemini", "config", "skills"), "config-review", "Review configs.") + writeAgySkill(t, filepath.Join(repo, ".agents", "skills"), "workspace-review", "Review the workspace.") + writeAgySkill(t, filepath.Join(repo, ".agent", "skills"), "legacy-review", "Review legacy layouts.") + + got, err := (&AntigravityAgent{}).DiscoverReviewSkills(context.Background()) + if err != nil { + t.Fatalf("DiscoverReviewSkills: %v", err) + } + names := map[string]bool{} + for _, s := range got { + names[s.Name] = true + } + for _, want := range []string{"/config-review", "/workspace-review", "/legacy-review"} { + if !names[want] { + t.Errorf("missing %s: %+v", want, got) + } + } +} + +func TestDiscoverReviewSkills_DedupesAcrossScopes(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Chdir(t.TempDir()) + + writeAgySkill(t, filepath.Join(home, ".gemini", "antigravity-cli", "skills"), "code-review", "Global.") + writeAgySkill(t, filepath.Join(home, ".gemini", "skills"), "code-review", "Shared.") + + got, err := (&AntigravityAgent{}).DiscoverReviewSkills(context.Background()) + if err != nil { + t.Fatalf("DiscoverReviewSkills: %v", err) + } + count := 0 + for _, s := range got { + if s.Name == "/code-review" { + count++ + } + } + if count != 1 { + t.Fatalf("want 1 /code-review after dedupe, got %d: %+v", count, got) + } +} + +func TestDiscoverReviewSkills_NoSkillsReturnsNil(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Chdir(t.TempDir()) + + got, err := (&AntigravityAgent{}).DiscoverReviewSkills(context.Background()) + if err != nil || got != nil { + t.Fatalf("want (nil, nil) on empty install, got (%+v, %v)", got, err) + } +} diff --git a/cmd/entire/cli/agent/antigravity/generate.go b/cmd/entire/cli/agent/antigravity/generate.go new file mode 100644 index 0000000000..0fe979242f --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/generate.go @@ -0,0 +1,43 @@ +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). +// +// The prompt travels in argv. Earlier releases accepted it on stdin behind a +// single-space -p placeholder (the Gemini CLI convention, verified on agy +// 1.0.16), but agy 1.2.7 ignores stdin in print mode: `-p " "` fails with +// "Error: empty prompt", and `-p -` is answered as the literal message "-" +// (both observed live, trail 444, 2026-09-22), which is how +// `entire dispatch --local --agent antigravity` came to hand agy an empty +// prompt. argv is the only documented route ("Usage: agy --print 'your +// prompt here'"). +// +// That makes prompt size this agent's problem in a way it is not for agents +// that use RunIsolatedTextGeneratorCLI's stdin. Linux caps a SINGLE argument +// at MAX_ARG_STRLEN (128 KiB) however large the total ARG_MAX is, so an +// unbounded prompt fails with E2BIG. summarize.maxCondensedTranscriptBytes is +// what keeps summary prompts inside it. Windows' ~32 KiB whole-command-line +// limit is tighter than any useful transcript budget and is not covered; a +// long enough prompt still fails there, loudly. +func (a *AntigravityAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { + args := []string{"-p", prompt} + if model != "" { + args = append(args, "--model", model) + } + result, capturedStderr, stdoutBytes, err := agent.RunIsolatedTextGeneratorCLI(ctx, a.CommandRunner, "agy", "antigravity", args, "") + if err != nil { + return "", &agent.TextGenerationError{ + Err: fmt.Errorf("antigravity text generation failed: %w", err), + Stderr: capturedStderr, + StdoutBytes: stdoutBytes, + } + } + return result, nil +} diff --git a/cmd/entire/cli/agent/antigravity/generate_test.go b/cmd/entire/cli/agent/antigravity/generate_test.go new file mode 100644 index 0000000000..2ce7d41ad2 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/generate_test.go @@ -0,0 +1,41 @@ +package antigravity + +import ( + "context" + "os/exec" + "testing" +) + +// agy 1.2.7 ignores stdin in print mode (`-p " "` fails with "empty prompt", +// `-p -` is answered as the literal message "-"), so the prompt must travel in +// argv. This pins the shape `entire dispatch --local --agent antigravity` and +// `explain --generate` depend on. +func TestGenerateText_PassesPromptInArgv(t *testing.T) { + t.Parallel() + var gotBinary string + var gotArgs []string + a := &AntigravityAgent{CommandRunner: func(ctx context.Context, binary string, argv ...string) *exec.Cmd { + gotBinary, gotArgs = binary, argv + return exec.CommandContext(ctx, "echo", "PONG") + }} + + out, err := a.GenerateText(context.Background(), "Summarize this transcript.", "gemini-3.8-flash-low") + if err != nil { + t.Fatalf("GenerateText: %v", err) + } + if out != "PONG" { + t.Fatalf("GenerateText output = %q, want the CLI's stdout", out) + } + if gotBinary != "agy" { + t.Fatalf("binary = %q, want agy", gotBinary) + } + want := []string{"-p", "Summarize this transcript.", "--model", "gemini-3.8-flash-low"} + if len(gotArgs) != len(want) { + t.Fatalf("args = %q, want %q", gotArgs, want) + } + for i := range want { + if gotArgs[i] != want[i] { + t.Fatalf("args = %q, want %q", gotArgs, want) + } + } +} diff --git a/cmd/entire/cli/agent/antigravity/hooks.go b/cmd/entire/cli/agent/antigravity/hooks.go new file mode 100644 index 0000000000..d8073d646a --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/hooks.go @@ -0,0 +1,341 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/paths" +) + +// Ensure AntigravityAgent implements HookSupport and declares where its hook +// config lives (HookConfigRelPath in antigravity.go), so doctor's symlink scan +// and the vouchable-directory guard cover .agents/hooks.json like every other +// agent's config. +var ( + _ agent.HookSupport = (*AntigravityAgent)(nil) + _ agent.HookConfigLocator = (*AntigravityAgent)(nil) +) + +// AgentsHooksFileName is the hooks file used by Antigravity. +const AgentsHooksFileName = "hooks.json" + +// InstallHooks installs Antigravity hooks in .agents/hooks.json. +// If force is true, removes existing Entire hooks before installing. +// Returns the number of hooks installed. +func (a *AntigravityAgent) InstallHooks(ctx context.Context, force bool) (int, error) { + cfg, err := a.hookConfig(ctx) + if err != nil { + return 0, err + } + + // Read and parse existing hooks file, preserving unknown keys + rawFile := make(map[string]json.RawMessage) + existingData, readErr := cfg.Read() + if readErr == nil { + if err := json.Unmarshal(existingData, &rawFile); err != nil { + return 0, fmt.Errorf("failed to parse existing hooks.json: %w", err) + } + } else if !errors.Is(readErr, os.ErrNotExist) { + return 0, readErr //nolint:wrapcheck // agent.HookConfigFile already names the file in its error + } + + // Build the candidate Entire hook config. The whole "entire" entry is + // replaced on install, so a hook written by an older version cannot + // survive alongside the current one. + candidate := buildEntireHookConfig() + + // Title tee: agy's only token-usage surface (same payload as the + // statusline script). Run this BEFORE the idempotency early-return: the + // title slot lives in agy's GLOBAL settings.json, independent of this + // repo's .agents/hooks.json. If repo hooks already match but the global + // slot is missing or stale (upgrade from a pre-title-tee version, a failed + // first install, or `entire agent add` without --force), re-running setup + // must still repair it — otherwise the doctor's "re-run setup" hint is a + // no-op. InstallTitleTee is itself idempotent. Best-effort: a failure to + // claim the global slot must not fail repo-level hook setup. + // + // Gated on agy actually being on PATH. The slot lives in agy's global + // settings.json, so claiming it from a machine that has never run agy + // writes a shared user-level file on the strength of a repo-local + // command — `entire agent add antigravity` in a teammate's checkout, say. + // `entire doctor` gates its matching check the same way. The order above + // is unchanged: this still runs before the idempotency early-return, so + // stale-slot repair keeps working wherever agy is installed. + if _, lookErr := exec.LookPath(antigravityBinaryName); lookErr != nil { + logging.Debug(ctx, "skipping antigravity title tee: agy is not on PATH", + "error", lookErr.Error()) + } else if err := InstallTitleTee(); err != nil { + logging.Warn(ctx, "failed to install antigravity title tee", + "error", err.Error()) + } + + // Idempotency check: an entry the user disabled or one that already matches + // the candidate is left alone. --force remains the explicit override. + if !force { + if existing, ok := rawFile["entire"]; ok { + disabled, same := entireEntryMatches(existing, candidate) + if disabled || same { + 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, cfg); err != nil { + return 0, err + } + + // 3 hooks: pre-tool-use, pre-invocation, stop + return 3, nil +} + +// entireEntryMatches compares an installed "entire" entry against candidate by +// re-marshaling both to compact JSON. disabled reports a user-set +// "enabled": false — agy's documented per-entry disable knob, a deliberate +// choice that install must not rewrite and silently re-arm. +func entireEntryMatches(existing json.RawMessage, candidate HookConfig) (disabled, same bool) { + var existingCfg HookConfig + if err := json.Unmarshal(existing, &existingCfg); err != nil { + return false, false + } + if existingCfg.Enabled != nil && !*existingCfg.Enabled { + return true, false + } + existingBytes, err1 := jsonutil.MarshalWithNoHTMLEscape(existingCfg) + candidateBytes, err2 := jsonutil.MarshalWithNoHTMLEscape(candidate) + return false, err1 == nil && err2 == nil && bytes.Equal(existingBytes, candidateBytes) +} + +// HooksEntryMatchesHost reports whether the repo's installed "entire" entry is +// exactly what InstallHooks would write on THIS host. installed is false when +// there is no entry. A user-disabled entry counts as current. +// +// It exists for `entire doctor`: the hook command's SHAPE is host-specific +// (agy runs it through cmd.exe on Windows and sh elsewhere), and a hooks.json +// committed from a macOS checkout carries a sh wrapper that cmd.exe tears +// apart — the hook exits 1, the failure shows only in agy's log, and nothing +// is tracked. A file that merely exists proves nothing about that; comparing +// against the candidate does, at zero cost and without spawning agy. +func (a *AntigravityAgent) HooksEntryMatchesHost(ctx context.Context) (installed, current bool, err error) { + cfg, err := a.hookConfig(ctx) + if err != nil { + return false, false, err + } + existing, ok, err := readEntireEntry(cfg) + if err != nil || !ok { + return false, false, err + } + disabled, same := entireEntryMatches(existing, buildEntireHookConfig()) + return true, disabled || same, nil +} + +// readEntireEntry returns the raw "entire" entry from the repo's hooks.json. +// ok is false when the file or the entry is absent, which every caller reads +// as "no Entire hooks here" rather than as an error. +// +// Parsed per-entry, not as a whole file of HookConfigs: foreign entries are +// free-form user content and need not match our struct shapes, and a strict +// whole-file unmarshal would fail on them and permanently report our own +// entry as missing. +func readEntireEntry(cfg *agent.HookConfigFile) (json.RawMessage, bool, error) { + data, err := cfg.Read() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + return nil, false, err //nolint:wrapcheck // agent.HookConfigFile already names the file in its error + } + var rawFile map[string]json.RawMessage + if err := json.Unmarshal(data, &rawFile); err != nil { + return nil, false, fmt.Errorf("parse hook config: %w", err) + } + entry, ok := rawFile["entire"] + return entry, ok, nil +} + +// HooksDisabled reports whether the repo's "entire" entry is present but +// explicitly switched off with "enabled": false. InstallHooks already treats +// that as a deliberate opt-out and leaves the entry alone. +// +// It exists so `entire doctor` can stay quiet about a configuration the user +// turned off. AreHooksInstalled and DetectPresence deliberately still report +// such an entry as present: they drive agent auto-detection and +// `entire agent list`, which describe what is on disk. +func (a *AntigravityAgent) HooksDisabled(ctx context.Context) (bool, error) { + cfg, err := a.hookConfig(ctx) + if err != nil { + return false, err + } + entry, ok, err := readEntireEntry(cfg) + if err != nil || !ok { + return false, err + } + var existingCfg HookConfig + if err := json.Unmarshal(entry, &existingCfg); err != nil { + return false, fmt.Errorf("parse entire hook entry: %w", err) + } + return existingCfg.Enabled != nil && !*existingCfg.Enabled, nil +} + +// hookConfig opens the repo's .agents/hooks.json through agent.HookConfigFile, +// which anchors on the worktree root and refuses a symlink at any component +// it creates directories under or writes through. +func (a *AntigravityAgent) hookConfig(ctx context.Context) (*agent.HookConfigFile, error) { + repoRoot, err := paths.WorktreeRoot(ctx) + if err != nil { + // Not a repository (tests, and `enable` before `git init`): the process + // directory is the only candidate, and it is a directory the caller + // chose rather than one derived from anything read off disk. The same + // fallback every other agent's hook config uses. + repoRoot = "." + } + return agent.OpenHookConfig(repoRoot, a.HookConfigRelPath()) //nolint:wrapcheck // agent.HookConfigFile already names the file in its error +} + +// UninstallHooks removes the Entire hook entry from .agents/hooks.json. +func (a *AntigravityAgent) UninstallHooks(ctx context.Context) error { + cfg, err := a.hookConfig(ctx) + if err != nil { + return err + } + data, err := cfg.Read() + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil // No hooks file means nothing to uninstall + } + return err //nolint:wrapcheck // agent.HookConfigFile already names the file in its error + } + + var rawFile map[string]json.RawMessage + if err := json.Unmarshal(data, &rawFile); err != nil { + return fmt.Errorf("failed to parse hooks.json: %w", err) + } + + // Nothing of ours in the file: leave it byte-for-byte alone. Rewriting it + // would re-indent and reorder a file that holds only the user's own + // entries, for no change of Entire's (the title-tee uninstall is careful + // about exactly this). + if _, ok := rawFile["entire"]; !ok { + return nil + } + delete(rawFile, "entire") + + return writeHooksFile(rawFile, cfg) +} + +// AreHooksInstalled checks if Entire hooks are installed. +func (a *AntigravityAgent) AreHooksInstalled(ctx context.Context) (bool, error) { + hookCfg, err := a.hookConfig(ctx) + if err != nil { + return false, err + } + entireRaw, ok, err := readEntireEntry(hookCfg) + if err != nil || !ok { + return false, err + } + var cfg HookConfig + if err := json.Unmarshal(entireRaw, &cfg); err != nil { + return false, fmt.Errorf("parse entire hook entry: %w", err) + } + + // 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), nil +} + +// stopHookTimeoutSeconds is the explicit timeout installed on the Stop +// handler. Stop runs PrepareTranscript (short bounded wait) plus SaveStep — a +// shadow-branch checkpoint write that can exceed agy's 30s default timeout on +// large repos, in which case agy kills the hook mid-checkpoint with no trace. +const stopHookTimeoutSeconds = 300 + +// buildEntireHookConfig constructs the HookConfig for the "entire" entry for +// the host this binary runs on. +func buildEntireHookConfig() HookConfig { + return buildEntireHookConfigForHost(agent.HookHostIsWindows()) +} + +// buildEntireHookConfigForHost constructs the "entire" entry for a Windows or +// POSIX hook host. agy hands every hook command to cmd.exe /C on Windows +// whatever else is installed (a Git Bash sh on PATH changes nothing), so the +// gate is agent.HookHostIsWindows, not the UseWindowsProductionHooks sh probe, +// and the wrapper is the bare direct-shell form: the sh wrapper is cut apart by +// cmd.exe (`>/dev/null` becomes a redirect to a missing path) and the nested +// cmd.exe form becomes one unrecognised program name. Both fail the hook with +// exit 1, visible only in agy's own log while the turn reports SUCCESS — +// silent, total loss of tracking (trail 444, confirmed on Windows 11 ARM64 with +// agy 1.2.7). +func buildEntireHookConfigForHost(windowsHost bool) HookConfig { + const cmdPrefix = "entire hooks antigravity " + makeCmd := func(verb string) string { + if windowsHost { + return agent.WrapWindowsProductionSilentHookCommandDirect(cmdPrefix + verb) + } + return agent.WrapProductionSilentHookCommand(cmdPrefix + verb) + } + + // PostToolUse and PostInvocation are deliberately NOT installed: neither + // maps to a lifecycle event, and installing them spawns a no-op `entire` + // subprocess on every completed tool call / model invocation. The struct + // fields stay in HookConfig so the idempotency comparison detects (and + // replaces) stale installs that still carry them. + return HookConfig{ + PreToolUse: []ToolHandler{ + { + Matcher: "*", + Hooks: []HookCommand{{Type: hookTypeCommand, Command: makeCmd("pre-tool-use")}}, + }, + }, + PreInvocation: []SimpleHandler{{Type: hookTypeCommand, Command: makeCmd("pre-invocation")}}, + Stop: []SimpleHandler{{Type: hookTypeCommand, Command: makeCmd("stop"), Timeout: stopHookTimeoutSeconds}}, + } +} + +// writeHooksFile marshals rawFile and writes it through cfg, which creates the +// parent directories inside the worktree root and refuses symlinks. +func writeHooksFile(rawFile map[string]json.RawMessage, cfg *agent.HookConfigFile) error { + output, err := jsonutil.MarshalIndentWithNewline(rawFile, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal hooks.json: %w", err) + } + return cfg.Write(output, 0o600) //nolint:wrapcheck // agent.HookConfigFile already names the file in its error +} + +// 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) { + 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) { + return true + } + } + return false +} diff --git a/cmd/entire/cli/agent/antigravity/hooks_probe.go b/cmd/entire/cli/agent/antigravity/hooks_probe.go new file mode 100644 index 0000000000..e40f5a51d7 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/hooks_probe.go @@ -0,0 +1,207 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os/exec" + "path/filepath" + "strings" + "time" + + "golang.org/x/mod/semver" +) + +// MinHooksProbeVersion is the first agy release whose print mode answers the +// read-only `/hooks` slash command locally — one tab-separated record per hook +// (or a structured payload under --output-format json) — "without starting an +// agent turn, spending quota or leaving a conversation behind" (agy 1.1.12 +// release notes). On older releases `-p "/hooks"` is sent to the model as +// literal prompt text, so the probe must never run there. +const MinHooksProbeVersion = "1.1.12" + +// antigravityBinaryName is the agy CLI binary looked up on PATH. +const antigravityBinaryName = "agy" + +// hooksProbeTimeout bounds the probe: agy still boots its language server for +// print mode, which takes a few seconds, but a hang (e.g. a stuck keyring +// prompt) must not stall `entire doctor`. +const hooksProbeTimeout = 30 * time.Second + +// HooksProbe is the outcome of asking agy which hooks it actually loads for a +// workspace. Loaded distinguishes "agy sees Entire's entry" from "the file is +// on disk but agy ignores it" — the latter is the untrusted-workspace trap +// (agy resolves an untrusted cwd to its scratch workspace, so no hooks fire). +type HooksProbe struct { + // Version is the agy version string reported by `agy --version`. + Version string + // Loaded is true when agy lists an Entire hook entry sourced from the + // workspace's .agents/hooks.json. + Loaded bool + // Sources are the hooks.json paths agy reported, for diagnostics. + Sources []string +} + +// ErrHooksProbeUnsupported is returned when the installed agy predates +// MinHooksProbeVersion; callers should report "cannot verify" rather than +// "not loaded". +var ErrHooksProbeUnsupported = errors.New("agy too old to answer /hooks in print mode") + +// ErrHooksProbeVersionUnknown is returned when `agy --version` printed +// something semver cannot parse (a build suffix, a banner line). It is +// deliberately distinct from ErrHooksProbeUnsupported: the agy may well be +// newer than the requirement, so "run `agy update`" would be wrong advice. +// The probe is skipped either way — a wrong guess is a real model turn. +var ErrHooksProbeVersionUnknown = errors.New("could not determine the agy version") + +// DoctorProbeEnv opts `entire doctor` into running ProbeLoadedHooks. Off by +// default: the probe's zero-quota argument rested on the version gate alone, +// and on Windows 11 with agy 1.2.7 `agy -p "/hooks"` was observed to run a +// full model turn (~12k input tokens) instead of answering locally. Until the +// local-answer behaviour is verified per platform rather than assumed from a +// version number, a default doctor run must not risk the user's quota. The +// probe also only proves agy PARSED hooks.json, not that the command in it can +// run; HooksEntryMatchesHost is the check that catches the failure that +// actually occurs. +const DoctorProbeEnv = "ENTIRE_ANTIGRAVITY_DOCTOR_PROBE" + +// ProbeLoadedHooks asks the agy binary on PATH which hooks it loads for +// repoRoot, via `agy -p /hooks --add-dir --output-format json`. +// --add-dir is what makes agy treat repoRoot as the workspace (the same flag +// the e2e harness relies on); without it the probe would answer for agy's +// scratch workspace and always report nothing loaded. +// +// Zero-quota by construction: the version gate guarantees agy answers /hooks +// locally. Returns ErrHooksProbeUnsupported for older agy, and any spawn or +// parse failure otherwise (an unauthenticated agy fails print mode with +// "authentication required", which surfaces here as an error). +func ProbeLoadedHooks(ctx context.Context, repoRoot string) (HooksProbe, error) { + probe := HooksProbe{} + // agy rejects a relative --add-dir but does not fail the run: it logs the + // rejection, loads zero hooks and answers for its scratch workspace, which + // would read here as "hooks not loaded". Refuse to ask the question wrong. + if !filepath.IsAbs(repoRoot) { + return probe, fmt.Errorf("agy --add-dir needs an absolute path, got %q", repoRoot) + } + agyPath, err := exec.LookPath(antigravityBinaryName) + if err != nil { + return probe, fmt.Errorf("agy not on PATH: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, hooksProbeTimeout) + defer cancel() + + versionOut, err := exec.CommandContext(ctx, agyPath, "--version").Output() + if err != nil { + return probe, fmt.Errorf("agy --version: %w", err) + } + probe.Version = strings.TrimSpace(string(versionOut)) + if err := classifyProbeVersion(probe.Version); err != nil { + return probe, err + } + + cmd := exec.CommandContext(ctx, agyPath, "-p", "/hooks", "--add-dir", repoRoot, "--output-format", "json") + cmd.Dir = repoRoot + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = strings.TrimSpace(stdout.String()) + } + return probe, fmt.Errorf("agy -p /hooks: %w: %s", err, firstLine(msg)) + } + + loaded, sources, err := parseHooksProbeOutput(stdout.Bytes(), filepath.Join(repoRoot, ".agents", AgentsHooksFileName)) + if err != nil { + return probe, err + } + probe.Loaded = loaded + probe.Sources = sources + return probe, nil +} + +// HooksProbeSupported reports whether an agy version string answers /hooks +// locally in print mode. Unparseable versions are treated as unsupported — +// the failure mode of a wrong guess is a real model turn on the user's quota. +func HooksProbeSupported(version string) bool { + return classifyProbeVersion(version) == nil +} + +// classifyProbeVersion returns nil for a version that answers /hooks locally, +// ErrHooksProbeVersionUnknown for one semver cannot parse, and +// ErrHooksProbeUnsupported for one that is too old. +func classifyProbeVersion(version string) error { + v := strings.TrimSpace(version) + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + if !semver.IsValid(v) { + return fmt.Errorf("%w: agy --version printed %q", ErrHooksProbeVersionUnknown, strings.TrimSpace(version)) + } + if semver.Compare(v, "v"+MinHooksProbeVersion) < 0 { + return fmt.Errorf("%w: have %s, need >= %s", ErrHooksProbeUnsupported, strings.TrimSpace(version), MinHooksProbeVersion) + } + return nil +} + +// hooksProbeEnvelope is the subset of agy's --output-format json envelope the +// probe reads: command.data.hooks[] carries one entry per hooks.json "name" +// key with the file it came from. +// agyProbeStatusSuccess is the status agy reports for a completed command. +const agyProbeStatusSuccess = "SUCCESS" + +type hooksProbeEnvelope struct { + Status string `json:"status"` + Command struct { + Name string `json:"name"` + Data struct { + Hooks []struct { + Name string `json:"name"` + Enabled bool `json:"enabled"` + Source string `json:"source"` + } `json:"hooks"` + } `json:"data"` + } `json:"command"` +} + +// parseHooksProbeOutput reports whether the envelope lists an enabled "entire" +// entry whose source is hooksPath (compared after symlink resolution on both +// sides, since agy reports the path it resolved). Sources of every listed +// entry are returned for diagnostics. +func parseHooksProbeOutput(out []byte, hooksPath string) (loaded bool, sources []string, err error) { + var env hooksProbeEnvelope + if err := json.Unmarshal(out, &env); err != nil { + return false, nil, fmt.Errorf("agy -p /hooks: unexpected output: %w", err) + } + // A non-success envelope carries no hooks, which is indistinguishable from + // a genuine empty list once the status is dropped — and the caller's + // remediation for an empty list is "your hooks are NOT LOADED", the wrong + // thing to tell someone whose probe failed. An absent status is left alone + // rather than treated as failure: only a status agy actually reported is + // evidence about the probe. + if env.Status != "" && !strings.EqualFold(env.Status, agyProbeStatusSuccess) { + return false, nil, fmt.Errorf("agy -p /hooks: reported status %q", env.Status) + } + wantPath := resolveAgySymlinks(hooksPath) + for _, h := range env.Command.Data.Hooks { + sources = append(sources, h.Source) + if h.Name != "entire" || !h.Enabled { + continue + } + if resolveAgySymlinks(h.Source) == wantPath { + loaded = true + } + } + return loaded, sources, nil +} + +func firstLine(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + return s[:i] + } + return s +} diff --git a/cmd/entire/cli/agent/antigravity/hooks_probe_test.go b/cmd/entire/cli/agent/antigravity/hooks_probe_test.go new file mode 100644 index 0000000000..e186c4a2a1 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/hooks_probe_test.go @@ -0,0 +1,88 @@ +package antigravity + +import ( + "path/filepath" + "testing" +) + +func TestHooksProbeSupported(t *testing.T) { + t.Parallel() + cases := map[string]bool{ + "1.1.22": true, + "1.1.12": true, + "v1.1.12": true, + "1.1.11": false, + "1.1.1": false, + "1.0.16": false, + "": false, + "dev": false, + } + for version, want := range cases { + if got := HooksProbeSupported(version); got != want { + t.Errorf("HooksProbeSupported(%q) = %v, want %v", version, got, want) + } + } +} + +func TestParseHooksProbeOutput(t *testing.T) { + t.Parallel() + dir := t.TempDir() + hooksPath := filepath.Join(dir, ".agents", "hooks.json") + + // Real 1.1.22 envelope shape (agy -p /hooks --output-format json). + out := []byte(`{"conversation_id":"","status":"SUCCESS","response":"entire\tenabled\tPreToolUse\t*\tcommand\tx\n","duration_seconds":0,"num_turns":0,"usage":{"input_tokens":0},"command":{"name":"hooks","data":{"hooks":[{"name":"entire","enabled":true,"source":"` + hooksPath + `","actions":[{"event":"PreToolUse","matcher":"*","type":"command","command":"x"}]}]}}}`) + loaded, sources, err := parseHooksProbeOutput(out, hooksPath) + if err != nil { + t.Fatal(err) + } + if !loaded { + t.Fatalf("want loaded=true for matching enabled entire entry, sources=%v", sources) + } + + // Disabled entry does not count. + disabled := []byte(`{"command":{"data":{"hooks":[{"name":"entire","enabled":false,"source":"` + hooksPath + `"}]}}}`) + loaded, _, err = parseHooksProbeOutput(disabled, hooksPath) + if err != nil || loaded { + t.Fatalf("disabled entire entry must not count as loaded: loaded=%v err=%v", loaded, err) + } + + // Another workspace's hooks.json does not count (untrusted-cwd trap). + other := []byte(`{"command":{"data":{"hooks":[{"name":"entire","enabled":true,"source":"/elsewhere/.agents/hooks.json"}]}}}`) + loaded, sources, err = parseHooksProbeOutput(other, hooksPath) + if err != nil || loaded { + t.Fatalf("other workspace source must not count: loaded=%v err=%v", loaded, err) + } + if len(sources) != 1 || sources[0] != "/elsewhere/.agents/hooks.json" { + t.Fatalf("sources = %v, want the reported path for diagnostics", sources) + } + + if _, _, err := parseHooksProbeOutput([]byte("not json"), hooksPath); err == nil { + t.Fatal("garbage output must error, not report loaded=false silently") + } +} + +// A failed probe and a genuine empty hook list both arrive with no hooks, so +// dropping the envelope's status made them indistinguishable — and doctor's +// remediation for an empty list tells the user their hooks are NOT LOADED, +// which is the wrong advice for someone whose probe never ran. +func TestParseHooksProbeOutput_RejectsNonSuccessStatus(t *testing.T) { + t.Parallel() + + hooksPath := filepath.Join(t.TempDir(), "hooks.json") + + loaded, _, err := parseHooksProbeOutput( + []byte(`{"status":"ERROR","command":{"name":"hooks","data":{"hooks":[]}}}`), hooksPath) + if err == nil { + t.Error("a non-success envelope must be reported as a failed probe, not as no hooks loaded") + } + if loaded { + t.Error("loaded must stay false for a failed probe") + } + + // An absent status is not evidence of failure: only a status agy actually + // reported says anything about the probe. + if _, _, err := parseHooksProbeOutput( + []byte(`{"command":{"name":"hooks","data":{"hooks":[]}}}`), hooksPath); err != nil { + t.Errorf("an envelope with no status must still parse: %v", err) + } +} 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..8996c00223 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/hooks_test.go @@ -0,0 +1,672 @@ +package antigravity + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + + "github.com/entireio/cli/cmd/entire/cli/osroot" +) + +func TestInstallHooks_FreshRepo(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + n, err := a.InstallHooks(context.Background(), false) + if err != nil { + t.Fatalf("InstallHooks: %v", err) + } + if n != 3 { + t.Errorf("installed %d hooks, want 3", 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.PreInvocation) != 1 || len(cfg.Stop) != 1 { + t.Errorf("event coverage incomplete: %+v", cfg) + } + // PostToolUse/PostInvocation are deliberately not installed: they have no + // lifecycle mapping and would spawn a no-op subprocess per tool call. + if len(cfg.PostToolUse) != 0 || len(cfg.PostInvocation) != 0 { + t.Errorf("no-op post hooks must not be installed: %+v", cfg) + } + if cfg.PreToolUse[0].Matcher != "*" { + t.Errorf("PreToolUse matcher = %q, want %q", cfg.PreToolUse[0].Matcher, "*") + } + // Stop runs PrepareTranscript + SaveStep (a shadow-branch checkpoint + // write); agy's default hook timeout is 30s, which a large repo can + // exceed — agy would kill the hook mid-checkpoint with no trace. The + // installed handler must carry an explicit generous timeout. + if cfg.Stop[0].Timeout != stopHookTimeoutSeconds { + t.Errorf("Stop timeout = %d, want %d", cfg.Stop[0].Timeout, stopHookTimeoutSeconds) + } +} + +func TestInstallHooks_Idempotent(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + + // First install + n, err := a.InstallHooks(context.Background(), false) + if err != nil { + t.Fatalf("first InstallHooks: %v", err) + } + if n != 3 { + t.Errorf("first install: installed %d hooks, want 3", n) + } + + // Second install — idempotent, should return 0 + n, err = a.InstallHooks(context.Background(), 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) + t.Setenv(configDirEnv, t.TempDir()) + + // 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) + if err != nil { + t.Fatalf("InstallHooks: %v", err) + } + if n != 3 { + t.Errorf("installed %d hooks, want 3", 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) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + + // Install entire hooks first + if _, err := a.InstallHooks(context.Background(), 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 TestAreHooksInstalled(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + + if installed, err := a.AreHooksInstalled(context.Background()); err != nil || installed { + t.Errorf("AreHooksInstalled() = (%v, %v) before install, want (false, nil)", installed, err) + } + + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + + if installed, err := a.AreHooksInstalled(context.Background()); err != nil || !installed { + t.Errorf("AreHooksInstalled() = (%v, %v) after install, want (true, nil)", installed, err) + } +} + +// TestAreHooksInstalled_ToleratesForeignEntryShapes pins detection tolerance: +// .agents/hooks.json is a shared, user-editable file, and foreign hook entries +// are free-form (agy only requires OUR entry to be well-shaped). A foreign +// entry whose fields don't match Entire's struct types (e.g. a string timeout) +// must not break detection of the entire entry — otherwise install succeeds +// while status/doctor permanently report "not installed". +func TestAreHooksInstalled_ToleratesForeignEntryShapes(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + + // Splice in a foreign entry with shapes that don't unmarshal into + // Entire's handler structs: string timeout, numeric command. + hooksPath := filepath.Join(tmpDir, ".agents", AgentsHooksFileName) + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + raw["users-own-linter"] = json.RawMessage(`{"PreInvocation":[{"command":42,"timeout":"5s"}],"Stop":"not-a-list"}`) + merged, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(hooksPath, merged, 0o600); err != nil { + t.Fatal(err) + } + + if installed, err := a.AreHooksInstalled(context.Background()); err != nil || !installed { + t.Errorf("AreHooksInstalled() = (%v, %v) with a malformed foreign entry present, want (true, nil)", installed, err) + } +} + +// TestInstallHooks_RespectsUserDisabledEntry pins that a user-set +// "enabled": false on the entire hooks entry (agy's documented per-entry +// disable knob) is a deliberate choice: a non-force reinstall must leave the +// entry untouched rather than rewriting it and silently re-arming tracking. +func TestInstallHooks_RespectsUserDisabledEntry(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + + // User disables the entry through agy's documented knob. + hooksPath := filepath.Join(tmpDir, ".agents", AgentsHooksFileName) + data, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := json.Unmarshal(raw["entire"], &cfg); err != nil { + t.Fatal(err) + } + cfg["enabled"] = false + entireBytes, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + raw["entire"] = entireBytes + merged, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(hooksPath, merged, 0o600); err != nil { + t.Fatal(err) + } + + n, err := a.InstallHooks(context.Background(), false) + if err != nil { + t.Fatalf("InstallHooks after disable: %v", err) + } + if n != 0 { + t.Errorf("InstallHooks rewrote a user-disabled entry (n=%d), want 0", n) + } + + after, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + var afterRaw map[string]json.RawMessage + if err := json.Unmarshal(after, &afterRaw); err != nil { + t.Fatal(err) + } + var afterCfg HookConfig + if err := json.Unmarshal(afterRaw["entire"], &afterCfg); err != nil { + t.Fatal(err) + } + if afterCfg.Enabled == nil || *afterCfg.Enabled { + t.Error("user-set enabled:false was dropped — hooks silently re-armed") + } + + // --force is the explicit override: it may rewrite (and re-arm) the entry. + if _, err := a.InstallHooks(context.Background(), true); err != nil { + t.Fatalf("InstallHooks --force: %v", err) + } +} + +// TestInstallHooks_IdempotentStillRepairsTitleTee guards the regression where +// the repo-hooks idempotency early-return skipped the global title-tee install, +// leaving Antigravity checkpoints without token counts after an upgrade or a +// failed first title install (and making the doctor's "re-run setup" hint a +// no-op). Re-running InstallHooks must repair the missing global slot even when +// the repo's .agents/hooks.json already matches. +func TestInstallHooks_IdempotentStillRepairsTitleTee(t *testing.T) { + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + putFakeAgyOnPath(t) + + a := &AntigravityAgent{} + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("first InstallHooks: %v", err) + } + if !TitleTeeInstalled() { + t.Fatal("title tee should be installed after the first InstallHooks") + } + + // Simulate a missing/stale global slot while repo hooks remain correct. + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + if TitleTeeInstalled() { + t.Fatal("precondition: title tee should be gone before the idempotent re-install") + } + + // Second install hits the repo-hooks idempotency early-return, but must + // still re-install the missing global title tee. + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("second InstallHooks: %v", err) + } + if !TitleTeeInstalled() { + t.Error("idempotent InstallHooks must repair the missing title tee") + } +} + +// TestHooks_RefuseSymlinkedAgentsDir pins the three operations that used to +// resolve .agents through a checked-in symlink with bare os.ReadFile / +// os.MkdirAll / a joined-path write. A repository shipping +// `.agents -> /somewhere/else` got hooks.json created and rewritten outside the +// worktree from InstallHooks, deleted-from outside it from UninstallHooks, and +// AreHooksInstalled read the far end as its own answer. It is reachable +// without the user naming antigravity: DetectPresence is AreHooksInstalled. +func TestHooks_RefuseSymlinkedAgentsDir(t *testing.T) { + outside := t.TempDir() + worktree := t.TempDir() + if err := os.Symlink(outside, filepath.Join(worktree, ".agents")); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + // The link's far end already holds an entire entry, so a followed read has + // something to report and a followed write has something to destroy. + planted := filepath.Join(outside, AgentsHooksFileName) + plantedBody := `{"entire":{"stop":[{"type":"command","command":"entire hooks antigravity stop"}]}}` + if err := os.WriteFile(planted, []byte(plantedBody), 0o600); err != nil { + t.Fatal(err) + } + t.Chdir(worktree) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + + if _, err := a.InstallHooks(context.Background(), false); !errors.Is(err, osroot.ErrSymlinkedPath) { + t.Errorf("InstallHooks err = %v, want osroot.ErrSymlinkedPath", err) + } + + // An unreadable answer is not "no hooks": a caller deciding whether hooks + // can be left alone must not be told the far end's content is ours. + installed, err := a.AreHooksInstalled(context.Background()) + if !errors.Is(err, osroot.ErrSymlinkedPath) { + t.Errorf("AreHooksInstalled err = %v, want osroot.ErrSymlinkedPath", err) + } + if installed { + t.Error("AreHooksInstalled followed the link and claimed the planted entry") + } + + if err := a.UninstallHooks(context.Background()); !errors.Is(err, osroot.ErrSymlinkedPath) { + t.Errorf("UninstallHooks err = %v, want osroot.ErrSymlinkedPath", err) + } + + got, err := os.ReadFile(planted) + if err != nil { + t.Fatalf("the file at the link's far end must survive untouched: %v", err) + } + if string(got) != plantedBody { + t.Errorf("hooks.json outside the worktree was rewritten:\n%s", got) + } +} + +// TestHooks_RefuseSymlinkedHooksFile: the leaf is refused too. os.Root blocks a +// link that escapes the worktree but follows one pointing elsewhere inside it, +// and accepting the leaf would let `.agents/hooks.json -> ../victim.json` +// redirect both the merge read and the subsequent write. +func TestHooks_RefuseSymlinkedHooksFile(t *testing.T) { + worktree := t.TempDir() + if err := os.MkdirAll(filepath.Join(worktree, ".agents"), 0o750); err != nil { + t.Fatal(err) + } + victim := filepath.Join(worktree, "victim.json") + if err := os.WriteFile(victim, []byte(`{"mine":true}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("..", "victim.json"), filepath.Join(worktree, ".agents", AgentsHooksFileName)); err != nil { + t.Skipf("symlinks unavailable: %v", err) + } + t.Chdir(worktree) + t.Setenv(configDirEnv, t.TempDir()) + + a := &AntigravityAgent{} + if _, err := a.InstallHooks(context.Background(), false); !errors.Is(err, osroot.ErrSymlinkedPath) { + t.Errorf("InstallHooks err = %v, want osroot.ErrSymlinkedPath", err) + } + got, err := os.ReadFile(victim) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"mine":true}` { + t.Errorf("the link's target was rewritten:\n%s", got) + } + info, err := os.Lstat(filepath.Join(worktree, ".agents", AgentsHooksFileName)) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Error("the user's symlink was replaced by a regular file") + } +} + +// agy runs hook commands through cmd.exe /C on Windows, so the installed +// command must be the bare direct-shell wrapper there: no `sh -c` (cmd.exe +// tears its redirects apart) and no nested `cmd.exe /d /s /c "…"` block (cmd.exe +// /C takes the quoted block as one program name). Confirmed on Windows 11 with +// agy 1.2.7 in trail 444. +func TestBuildEntireHookConfig_WindowsHostUsesDirectCmdWrapper(t *testing.T) { + t.Parallel() + + cfg := buildEntireHookConfigForHost(true) + commands := []string{ + cfg.PreToolUse[0].Hooks[0].Command, + cfg.PreInvocation[0].Command, + cfg.Stop[0].Command, + } + for _, cmd := range commands { + if strings.HasPrefix(cmd, "sh -c") { + t.Errorf("Windows host got the sh wrapper: %s", cmd) + } + if strings.HasPrefix(cmd, "cmd.exe") { + t.Errorf("Windows host got the nested cmd.exe wrapper, which agy's own cmd.exe /C rejects: %s", cmd) + } + if !strings.HasPrefix(cmd, "where.exe entire >nul 2>nul & if errorlevel 1 (ver>nul) else (entire hooks antigravity ") { + t.Errorf("unexpected Windows hook command shape: %s", cmd) + } + if !agent.IsManagedHookCommand(cmd) { + t.Errorf("uninstall and drift detection must still recognise the Windows command as Entire's: %s", cmd) + } + } + + posix := buildEntireHookConfigForHost(false) + if !strings.HasPrefix(posix.Stop[0].Command, "sh -c ") { + t.Errorf("POSIX host must keep the sh wrapper, got %s", posix.Stop[0].Command) + } +} + +// A hooks.json that never carried an Entire entry is the user's file: uninstall +// must not rewrite it (re-indented, keys reordered) for no change of ours. +func TestUninstallHooks_LeavesAForeignOnlyFileUntouched(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + t.Setenv(configDirEnv, t.TempDir()) + + hooksPath := filepath.Join(dir, ".agents", AgentsHooksFileName) + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + t.Fatal(err) + } + // Deliberately odd formatting: four-space indent, keys out of sorted order. + original := "{\n \"zeta\": {\"Stop\": [{\"type\": \"command\", \"command\": \"echo z\"}]},\n \"alpha\": {\"PreInvocation\": [{\"type\": \"command\", \"command\": \"echo a\"}]}\n}\n" + if err := os.WriteFile(hooksPath, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + + if err := (&AntigravityAgent{}).UninstallHooks(context.Background()); err != nil { + t.Fatalf("UninstallHooks: %v", err) + } + got, err := os.ReadFile(hooksPath) + if err != nil { + t.Fatal(err) + } + if string(got) != original { + t.Fatalf("foreign-only hooks.json was rewritten:\n%s", got) + } +} + +// HooksEntryMatchesHost is doctor's zero-cost replacement for the probe: it +// must call a fresh install current, a foreign-shaped entry stale, and a +// user-disabled entry current (install leaves it alone too). +func TestHooksEntryMatchesHost(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + t.Setenv(configDirEnv, t.TempDir()) + a := &AntigravityAgent{} + ctx := context.Background() + + installed, current, err := a.HooksEntryMatchesHost(ctx) + if err != nil || installed || current { + t.Fatalf("no file: installed=%v current=%v err=%v; want false,false,nil", installed, current, err) + } + + if _, err := a.InstallHooks(ctx, false); err != nil { + t.Fatal(err) + } + installed, current, err = a.HooksEntryMatchesHost(ctx) + if err != nil || !installed || !current { + t.Fatalf("fresh install: installed=%v current=%v err=%v; want true,true,nil", installed, current, err) + } + + hooksPath := filepath.Join(dir, ".agents", AgentsHooksFileName) + stale := `{"entire":{"PreInvocation":[{"type":"command","command":"sh -c 'exec entire hooks antigravity pre-invocation'"}]}}` + if err := os.WriteFile(hooksPath, []byte(stale), 0o600); err != nil { + t.Fatal(err) + } + installed, current, err = a.HooksEntryMatchesHost(ctx) + if err != nil || !installed || current { + t.Fatalf("foreign shape: installed=%v current=%v err=%v; want true,false,nil", installed, current, err) + } + + disabled := `{"entire":{"enabled":false,"PreInvocation":[{"type":"command","command":"echo off"}]}}` + if err := os.WriteFile(hooksPath, []byte(disabled), 0o600); err != nil { + t.Fatal(err) + } + installed, current, err = a.HooksEntryMatchesHost(ctx) + if err != nil || !installed || !current { + t.Fatalf("user-disabled: installed=%v current=%v err=%v; want true,true,nil", installed, current, err) + } +} + +// "enabled": false is a deliberate opt-out that InstallHooks already honours. +// It has to be readable on its own, separately from installed-ness: the entry +// stays genuinely present for agent detection and `entire agent list`, and only +// `entire doctor` wants to go quiet about it. +func TestHooksDisabled_SeparatesOptOutFromPresence(t *testing.T) { + for name, tc := range map[string]struct { + entry string + wantDisabled bool + }{ + "explicitly disabled": {`{"enabled":false,"Stop":[{"type":"command","command":"entire hooks antigravity stop"}]}`, true}, + "explicitly enabled": {`{"enabled":true,"Stop":[{"type":"command","command":"entire hooks antigravity stop"}]}`, false}, + "enabled unset": {`{"Stop":[{"type":"command","command":"entire hooks antigravity stop"}]}`, false}, + } { + t.Run(name, func(t *testing.T) { + // No t.Parallel — uses t.Chdir and t.Setenv + tmpDir := t.TempDir() + t.Chdir(tmpDir) + t.Setenv(configDirEnv, t.TempDir()) + + agentsDir := filepath.Join(tmpDir, ".agents") + if err := os.MkdirAll(agentsDir, 0o750); err != nil { + t.Fatal(err) + } + hooks := `{"entire":` + tc.entry + `}` + if err := os.WriteFile(filepath.Join(agentsDir, AgentsHooksFileName), []byte(hooks), 0o600); err != nil { + t.Fatal(err) + } + + a := &AntigravityAgent{} + disabled, err := a.HooksDisabled(context.Background()) + if err != nil { + t.Fatalf("HooksDisabled: %v", err) + } + if disabled != tc.wantDisabled { + t.Errorf("HooksDisabled() = %v, want %v", disabled, tc.wantDisabled) + } + + // Present either way: detection and `entire agent list` describe + // what is on disk, so the opt-out must not make the entry vanish. + installed, err := a.AreHooksInstalled(context.Background()) + if err != nil { + t.Fatalf("AreHooksInstalled: %v", err) + } + if !installed { + t.Error("AreHooksInstalled() = false; a disabled entry is still present") + } + }) + } +} + +// putFakeAgyOnPath makes InstallHooks see an installed agy: the global title +// slot is only claimed on machines where `agy` resolves on PATH, so a test that +// expects the tee to be installed has to stand one up. The file is never run. +func putFakeAgyOnPath(t *testing.T) { + t.Helper() + binDir := t.TempDir() + name := antigravityBinaryName + if runtime.GOOS == "windows" { + name += ".exe" + } + if err := os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatalf("write fake agy: %v", err) + } + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// The title slot lives in agy's machine-global settings.json, so a repo-local +// `entire agent add antigravity` must not claim it on a machine that has never +// run agy. `entire doctor` gates its matching check on the same lookup. +func TestInstallHooks_SkipsTitleTeeWhenAgyIsAbsent(t *testing.T) { + // No t.Parallel — uses t.Chdir and t.Setenv + tmpDir := t.TempDir() + t.Chdir(tmpDir) + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + // An empty PATH is the "agy was never installed here" machine. + t.Setenv("PATH", t.TempDir()) + + a := &AntigravityAgent{} + if _, err := a.InstallHooks(context.Background(), false); err != nil { + t.Fatalf("InstallHooks: %v", err) + } + + // Repo hooks still installed: the global slot is a separate concern. + installed, err := a.AreHooksInstalled(context.Background()) + if err != nil { + t.Fatalf("AreHooksInstalled: %v", err) + } + if !installed { + t.Error("repo hooks must install even when agy is absent") + } + + if TitleTeeInstalled() { + t.Error("the machine-global title slot was claimed on a machine with no agy") + } + if _, err := os.Stat(filepath.Join(cfgDir, agySettingsFileName)); err == nil { + t.Error("agy's global settings.json was created on a machine that never ran agy") + } +} diff --git a/cmd/entire/cli/agent/antigravity/lifecycle.go b/cmd/entire/cli/agent/antigravity/lifecycle.go new file mode 100644 index 0000000000..31574f777a --- /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`. +// +// Only the hooks with lifecycle significance are installed. agy also offers +// PostToolUse and PostInvocation, but neither maps to an Entire lifecycle +// event (PostInvocation fires before the transcript is written; PostToolUse +// duplicates what PreToolUse already captures) — installing them would spawn +// a no-op `entire` subprocess on every completed tool call and model +// invocation. +const ( + HookNamePreToolUse = "pre-tool-use" + HookNamePreInvocation = "pre-invocation" + HookNameStop = "stop" +) + +// HookNames returns the hook verbs Antigravity supports. +// These become subcommands: entire hooks antigravity +func (a *AntigravityAgent) HookNames() []string { + return []string{ + HookNamePreToolUse, + HookNamePreInvocation, + HookNameStop, + } +} + +// ParseHookEvent translates an Antigravity hook into a normalized lifecycle Event. +// Returns nil if the hook has no lifecycle significance. +func (a *AntigravityAgent) ParseHookEvent(_ context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { + switch hookName { + case HookNamePreInvocation: + return parsePreInvocation(stdin) + case HookNameStop: + return parseStop(stdin) + case HookNamePreToolUse: + return parsePreToolUse(stdin) + 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: invocationNum is **0-indexed** (the docs now state this +// explicitly: "the first invocation is 0"). 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.) +// +// Resumes (agy --continue / --conversation) start with invocationNum > 0, so +// invocationNum alone can't distinguish them from a mid-turn follow-up. We emit +// a TurnStart with SuppressIfSessionActive for every invocationNum > 0; the cli +// dispatcher fires it only when no active session state exists — so a resumed +// turn (state condensed/idle/absent) is tracked, while a genuine follow-up +// (state active mid-turn) is dropped without clobbering the baseline. +// +// 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 + } + // invocationNum>0 is ambiguous: a mid-turn follow-up model call (must NOT + // re-fire TurnStart, or the pre-prompt baseline is clobbered) OR the first + // call of a resumed conversation (agy --continue / --conversation), which + // starts at invocationNum>0 and MUST be tracked. We can't read session state + // here (agent packages must not import strategy), so emit a conditional + // TurnStart and let the dispatcher fire it only when no active session + // exists. + return &agent.Event{ + Type: agent.TurnStart, + SessionID: raw.ConversationID, + SessionRef: raw.TranscriptPath, + Timestamp: time.Now(), + SuppressIfSessionActive: raw.InvocationNum != 0, + }, nil +} + +// 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) + // Terminates without a depth cap: filepath.Dir is lexical and strictly + // shortens dir on every step until it reaches the root, where Dir(dir) == + // dir. Symlink cycles cannot affect that; EvalSymlinks handles them + // internally (it returns an error past its own hop limit), and the walk + // never follows what it resolved. + 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, which callers that +// only want a path or a name can treat as absent. +func decodeAgyString(raw json.RawMessage) string { + s, _ := decodeAgyStringOK(raw) + return s +} + +// decodeAgyStringOK is decodeAgyString with the decode result reported +// separately, for callers that must tell a legitimately empty string from a +// value that is not a string at all. Collapsing the two corrupts agy's +// double-encoded empty string: it decodes to "", and a caller that reads "" +// as failure falls back to a plain decode of the raw value, yielding the +// two-character literal `""`. +func decodeAgyStringOK(raw json.RawMessage) (string, bool) { + if len(raw) == 0 { + return "", false + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "", false + } + // 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, true + } + return s, true +} + +// 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..eabfb8d88f --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/lifecycle_test.go @@ -0,0 +1,441 @@ +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, + HookNamePreInvocation, + 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, + }, + InvocationNum: 0, + } + 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_FollowUpEmitsConditionalTurnStart verifies +// that agy's per-model-call PreInvocations (invocationNum > 0) emit a TurnStart +// flagged SuppressIfSessionActive rather than nil. +// +// invocationNum>0 is ambiguous: it is EITHER a mid-turn follow-up model call +// (which must NOT re-fire TurnStart — re-capturing pre-prompt state clobbers +// the baseline TurnEnd diffs against, producing "no files modified, skipping +// checkpoint") OR the first call of a resumed conversation (agy --continue / +// --conversation), which starts at invocationNum>0 and MUST be tracked. The +// parser can't tell them apart without session state, so it defers the decision +// to the cli dispatcher via SuppressIfSessionActive: fire only when no active +// session exists. +// +// agy 1.0.0 ships invocationNum **0-indexed**; the fixture +// testdata/hook_stdin_pre_invocation.json carries invocationNum=1 (a follow-up). +func TestParseHookEvent_PreInvocation_FollowUpEmitsConditionalTurnStart(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 || ev.Type != agent.TurnStart { + t.Fatalf("expected a conditional TurnStart for invocationNum>0, got %+v", ev) + } + if !ev.SuppressIfSessionActive { + t.Error("follow-up/resume TurnStart must set SuppressIfSessionActive so the dispatcher gates on session state") + } +} + +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, + }, + 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, + }, + ToolCall: ToolCall{ + Name: "write_to_file", + 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 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, + }, + ToolCall: ToolCall{ + Name: "write_to_file", + 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 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, + }, + ToolCall: ToolCall{Name: "write_to_file", Args: json.RawMessage(argsRaw)}, + } + 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) + } +} + +// TestParseHookEvent_UnknownHookReturnsNil pins the default case: hook names +// we don't handle (including agy's PostToolUse/PostInvocation, which are no +// longer installed) parse to a nil event rather than an error, so a stale +// hooks.json entry can never fail an agy turn. +func TestParseHookEvent_UnknownHookReturnsNil(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + for _, name := range []string{"post-tool-use", "post-invocation", "does-not-exist"} { + ev, err := a.ParseHookEvent(context.Background(), name, bytes.NewReader([]byte(`{}`))) + if err != nil { + t.Fatalf("ParseHookEvent(%q): %v", name, err) + } + if ev != nil { + t.Errorf("expected nil event for unhandled hook %q, got %+v", name, ev) + } + } +} diff --git a/cmd/entire/cli/agent/antigravity/statusline.go b/cmd/entire/cli/agent/antigravity/statusline.go new file mode 100644 index 0000000000..8b01516f9b --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/statusline.go @@ -0,0 +1,619 @@ +package antigravity + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/internal/flock" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/osroot" + "github.com/entireio/cli/internal/entireclient/userdirs" +) + +// agy's title/statusline hook pipes a state JSON to the configured command on +// every agent state change. The context_window object is the ONLY surface +// where agy exposes token usage — it never appears in transcripts or +// lifecycle hook payloads. AppendStatusSnapshot persists those snapshots so +// the lifecycle can compute per-checkpoint deltas later. +// +// Totals are cumulative per conversation; current_usage is the latest API call. + +// statusDirEnv overrides the snapshot cache directory (tests, ops). +const statusDirEnv = "ENTIRE_ANTIGRAVITY_STATUS_DIR" + +// statusRetention is how long snapshot files for other conversations are kept. +const statusRetention = 14 * 24 * time.Hour + +// statusLockSuffix is appended to a conversation's snapshot file name to form +// the advisory lock file AppendStatusSnapshot serialises on. +const statusLockSuffix = ".lock" + +// statusCurrentUsage mirrors context_window.current_usage in the payload. +type statusCurrentUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` +} + +// statusContextWindow mirrors context_window in the payload. +type statusContextWindow struct { + TotalInputTokens int `json:"total_input_tokens"` + TotalOutputTokens int `json:"total_output_tokens"` + ContextWindowSize int `json:"context_window_size,omitempty"` + CurrentUsage *statusCurrentUsage `json:"current_usage,omitempty"` +} + +// statusSnapshot is one persisted line in .jsonl. +type statusSnapshot struct { + Timestamp string `json:"ts"` + ConversationID string `json:"conversation_id"` + ContextWindow statusContextWindow `json:"context_window"` +} + +// statuslinePayload is the subset of agy's state JSON we consume. +type statuslinePayload struct { + ConversationID string `json:"conversation_id"` + ContextWindow *statusContextWindow `json:"context_window"` +} + +// statusStore is where snapshot files live: a shared *os.Root anchor plus the +// directory name inside it (docs/development/filesystem-safety.md, "The Root +// Anchors"). Every read, append, lock and prune below is a NAME inside this +// root, so a symlink planted at any component is refused instead of followed. +type statusStore struct { + root *os.Root + // dir is the directory inside root holding the JSONL files. "" means the + // root itself is the directory (the override case). + dir string +} + +// statusDefaultDir is the store's location inside the per-user cache root. +// Slash-separated on every platform: names inside an os.Root are slash paths +// by contract, and osroot splits them on "/" only — a filepath.Join here made +// "antigravity\status" a single component on Windows, so the store was never +// created and no snapshot was ever persisted there (trail 444). +const statusDefaultDir = "antigravity/status" + +// openStatusStore resolves the store. It honours the ENTIRE_ANTIGRAVITY_STATUS_DIR +// env override (tests, ops), otherwise anchors on userdirs.CacheRoot. userdirs is +// the mandated resolver: it honours $XDG_CACHE_HOME on every platform +// (os.UserCacheDir ignores it on darwin, defeating harness isolation), falls back +// to a throwaway per-process dir under `go test`, and refuses a relative +// override before anything is created. The override is held to the same rule +// (RequireAbsoluteOverride) and opened through the shared registry like every +// other anchor, never as filepath.Dir of the file about to be written. +func openStatusStore() (statusStore, error) { + if override := os.Getenv(statusDirEnv); override != "" { + if err := userdirs.RequireAbsoluteOverride(statusDirEnv, override); err != nil { + return statusStore{}, fmt.Errorf("antigravity status: %w", err) + } + if err := userdirs.EnsurePrivateDir(override); err != nil { + return statusStore{}, fmt.Errorf("antigravity status: %w", err) + } + root, err := osroot.Shared(override) + if err != nil { + return statusStore{}, fmt.Errorf("antigravity status: open %s: %w", statusDirEnv, err) + } + return statusStore{root: root}, nil + } + root, err := userdirs.CacheRoot() + if err != nil { + return statusStore{}, fmt.Errorf("antigravity status: resolve cache dir: %w", err) + } + return statusStore{root: root, dir: statusDefaultDir}, nil +} + +// dirName is the store directory as a name inside the root ("." for the root). +func (st statusStore) dirName() string { + if st.dir == "" { + return "." + } + return st.dir +} + +// fileName returns the slash-separated name inside the root of a +// conversation's JSONL file. filepath.Base guards against path traversal in +// the conversation ID (it strips either separator on Windows). +func (st statusStore) fileName(conversationID string) string { + return path.Join(st.dir, filepath.Base(conversationID)+".jsonl") +} + +// statusFilePath returns the absolute path of a conversation's snapshot file. +// Diagnostics and tests only: production I/O goes through the root by name. +func statusFilePath(conversationID string) (string, error) { + st, err := openStatusStore() + if err != nil { + return "", err + } + return filepath.Join(st.root.Name(), filepath.FromSlash(st.fileName(conversationID))), nil +} + +// AppendStatusSnapshot parses an agy state-JSON payload and appends a snapshot +// to the per-conversation JSONL file. The hot path never returns an error for +// malformed input — only for genuine I/O failures. +// +// agy fires the title command on every agent state change and does not +// serialize the invocations, so two tees can run at once. The dedup read and +// the append therefore happen under one advisory lock per conversation +// (.jsonl.lock, next to the file); without it a concurrent tee could append +// between the read and the write and the dedup would miss. +func AppendStatusSnapshot(payload []byte) error { + var p statuslinePayload + if err := json.Unmarshal(payload, &p); err != nil { + return nil + } + if p.ConversationID == "" || p.ContextWindow == nil { + return nil // missing required fields — silently skip + } + + // Dedup: compare compact JSON of the new context_window against the last + // persisted line's context_window. + newCWBytes, err := json.Marshal(p.ContextWindow) + if err != nil { + return nil + } + + st, err := openStatusStore() + if err != nil { + return err + } + if st.dir != "" { + if err := osroot.MkdirAllNoSymlink(st.root, st.dir, 0o750); err != nil { + return fmt.Errorf("antigravity status: mkdir: %w", err) + } + } + name := st.fileName(p.ConversationID) + + release, err := lockStatusFile(st, name) + if err != nil { + return err + } + defer release() + + f, err := osroot.OpenFileNoFollow(st.root, name, os.O_RDWR|os.O_APPEND|os.O_CREATE, 0o600) + if err != nil { + return fmt.Errorf("antigravity status: open: %w", err) + } + defer func() { _ = f.Close() }() + + info, err := f.Stat() + if err != nil { + return fmt.Errorf("antigravity status: stat: %w", err) + } + isNew := info.Size() == 0 + if !isNew { + lastSnap, readErr := readLastSnapshotFrom(f) + if readErr == nil && lastSnap != nil { + lastCWBytes, marshalErr := json.Marshal(lastSnap.ContextWindow) + if marshalErr == nil && bytes.Equal(newCWBytes, lastCWBytes) { + return nil // duplicate — skip + } + } + } + + snap := statusSnapshot{ + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + ConversationID: p.ConversationID, + ContextWindow: *p.ContextWindow, + } + line, err := json.Marshal(snap) + if err != nil { + return nil + } + line = append(line, '\n') + if _, err := f.Write(line); err != nil { + return fmt.Errorf("antigravity status: write: %w", err) + } + + // Best-effort prune of stale files for other conversations when we first + // create the active file (avoids per-append overhead). + if isNew { + pruneStaleStatusFiles(st, p.ConversationID) + } + + return nil +} + +// SnapshotTokenBaseline returns the latest persisted snapshot for the +// conversation, or nil if none exists yet. A nil baseline is exact only for a +// genuinely fresh conversation; a resumed conversation whose title-tee shim +// hasn't written a snapshot before the first TurnStart will over-count the +// prior cumulative total on that first tracked turn. +func (a *AntigravityAgent) SnapshotTokenBaseline(ctx context.Context, sessionID string) (json.RawMessage, error) { + st, err := openStatusStore() + if err != nil { + return nil, nil //nolint:nilerr // ditto: an unusable status dir means no baseline + } + snap, err := readLastStatusSnapshot(ctx, st, st.fileName(sessionID)) + if err != nil || snap == nil { + return nil, nil //nolint:nilerr // ditto (missing file, no lines, malformed) + } + raw, err := json.Marshal(snap) + if err != nil { + return nil, nil //nolint:nilerr // ditto + } + return raw, nil +} + +// CalculateTokenUsageSince computes the delta between the baseline snapshot +// and the latest persisted snapshot. +// +// Exact: InputTokens/OutputTokens (cumulative totals minus baseline totals). +// Best-effort: cache fields and APICallCount, derived from the snapshot lines +// appended after the baseline timestamp (the dedup writer appends ~one line +// per API response, but lines can be missed between agent state changes). +func (a *AntigravityAgent) CalculateTokenUsageSince(ctx context.Context, sessionID string, baseline json.RawMessage) (*agent.TokenUsage, error) { + snaps, err := readStatusSnapshots(ctx, sessionID) + if err != nil || len(snaps) == 0 { + return nil, nil //nolint:nilerr,nilnil // no data -> no token counts, never an error + } + + var base statusSnapshot + if len(baseline) > 0 { + _ = json.Unmarshal(baseline, &base) //nolint:errcheck // unparseable baseline -> zero baseline + } + + latest := snaps[len(snaps)-1] + usage := &agent.TokenUsage{ + InputTokens: max(0, latest.ContextWindow.TotalInputTokens-base.ContextWindow.TotalInputTokens), + OutputTokens: max(0, latest.ContextWindow.TotalOutputTokens-base.ContextWindow.TotalOutputTokens), + } + + // The strictly-after (.After, not >=) filter is load-bearing for + // multi-turn correctness: turn N+1's baseline IS turn N's latest snapshot, + // so excluding the equal-timestamp boundary line prevents re-counting it. + // Changing this to >= would double-count the boundary line every turn. + baseTS, baseTSErr := time.Parse(time.RFC3339Nano, base.Timestamp) + for _, s := range snaps { + // If baseTS is unparseable we count cache/apicalls over all lines; accepted because input/output remain exact via the totals delta. + if base.Timestamp != "" && baseTSErr == nil { + ts, parseErr := time.Parse(time.RFC3339Nano, s.Timestamp) + if parseErr != nil || !ts.After(baseTS) { + continue + } + } + usage.APICallCount++ + if cu := s.ContextWindow.CurrentUsage; cu != nil { + usage.CacheCreationTokens += cu.CacheCreationInputTokens + usage.CacheReadTokens += cu.CacheReadInputTokens + } + } + + if usage.InputTokens == 0 && usage.OutputTokens == 0 && usage.CacheCreationTokens == 0 && usage.CacheReadTokens == 0 { + return nil, nil //nolint:nilnil // nothing observed this turn + } + return usage, nil +} + +// statusTailWindow bounds how many bytes readLastSnapshotFrom reads from the +// end of the file. Snapshot lines are well under 1 KB, so 64 KB always covers +// the final line with huge margin. +const statusTailWindow = 64 * 1024 + +// snapshotFileExists reports whether a conversation's snapshot file is present +// in the store, without following a symlink at any component. It is checked +// BEFORE a reader takes the conversation lock: flock.AcquireIn creates the lock +// file it opens, so a baseline read at every TurnStart on a conversation that +// never produces a snapshot — or whose snapshot was already pruned — would +// otherwise leave an orphan .jsonl.lock behind for the whole retention +// window (the prune cannot tell such an orphan from a lock a tee has just taken +// while creating its file). The check-then-lock gap is deliberate and cheap: a +// tee creating the file in between costs one turn's baseline, which lands in +// the same "no snapshot yet" degradation these readers already document. +func snapshotFileExists(st statusStore, name string) (bool, error) { + if _, err := osroot.LstatNoSymlinks(st.root, name); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("antigravity status: stat: %w", err) + } + return true, nil +} + +// readLastStatusSnapshot opens name inside the store and returns its final +// snapshot; a missing file is nil, nil. +func readLastStatusSnapshot(ctx context.Context, st statusStore, name string) (*statusSnapshot, error) { + if exists, err := snapshotFileExists(st, name); err != nil { + return nil, err + } else if !exists { + return nil, nil //nolint:nilnil // no snapshot yet — and no lock file left behind for it + } + release, err := lockStatusFileForRead(ctx, st, name) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil //nolint:nilnil // no status directory yet means no snapshots yet + } + return nil, err + } + defer release() + f, err := osroot.OpenNoFollow(st.root, name) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil //nolint:nilnil // a missing file is "no snapshots yet", not an error + } + return nil, fmt.Errorf("antigravity status: open: %w", err) + } + defer func() { _ = f.Close() }() + return readLastSnapshotFrom(f) +} + +// lockStatusFile takes the per-conversation advisory lock that +// AppendStatusSnapshot writes under. The readers take it too: agy does not +// serialise its title-command invocations, so a baseline or delta read that +// ran unlocked could observe the last line half-written by a concurrent tee +// and treat the torn JSON as "no snapshot" — a silently dropped token +// baseline for that turn. The lock file is created on first use; a status +// directory that does not exist yet is reported through fs.ErrNotExist. +// +// The writer waits without bound: it IS the tee, its critical section is one +// read and one append, and serialising the appends is the whole point. +func lockStatusFile(st statusStore, name string) (release func(), err error) { + release, err = flock.AcquireIn(st.root, name+statusLockSuffix) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, err //nolint:wrapcheck // preserved so callers can read it as "no snapshots yet" + } + return nil, fmt.Errorf("antigravity status: lock: %w", err) + } + return release, nil +} + +// pruneStaleSnapshot removes one stale conversation's snapshot file, holding +// that conversation's lock if it has one. Reports whether the file is gone. +// +// A lock file beside the snapshot means some tee has been mid-append for this +// conversation, so take that lock — non-blocking — before unlinking, the way +// the orphan-lock loop is careful about its own: a dormant conversation +// resumed between the caller's stat and this unlink is mid-append, and pulling +// the file from under its open fd loses the per-line detail between the +// baseline and now. A held lock means it is alive right now, which is reason +// enough to leave it for the next prune. +// +// No lock file means no tee ever was, because AppendStatusSnapshot takes the +// lock BEFORE creating the .jsonl — the ordering the orphan-lock loop already +// relies on. So absence is evidence here, and the file is unlinked directly. +// The try-lock is not a free probe: flock opens with O_CREATE|O_EXCL, so +// asking for a lock that does not exist CREATES one, and it would be left +// behind for a conversation that is being deleted — an orphan the loop below +// cannot collect this pass (it walks an entries snapshot taken before the lock +// existed) and will not collect on the next one until it has aged past the +// cutoff itself. +func pruneStaleSnapshot(st statusStore, name string) bool { + target := path.Join(st.dir, name) + hasLock, err := snapshotFileExists(st, target+statusLockSuffix) + if err != nil { + return false + } + if hasLock { + release, locked := tryLockStatusFile(st, target) + if !locked { + return false + } + defer release() + } + return osroot.RemoveNoSymlinks(st.root, target) == nil +} + +// tryLockStatusFile takes a conversation's lock only if it is free right now. +// An already-expired deadline selects flock's non-blocking path: one LOCK_NB +// attempt, then the context error rather than a wait. locked is false when +// another process holds it, and for a lock file that cannot be created — +// both mean "do not touch this conversation's snapshots". +func tryLockStatusFile(st statusStore, name string) (release func(), locked bool) { + ctx, cancel := context.WithTimeout(context.Background(), 0) + defer cancel() + release, err := flock.AcquireContextIn(ctx, st.root, name+statusLockSuffix) + if err != nil { + return nil, false + } + return release, true +} + +// statusReadLockTimeout bounds how long a reader waits for the conversation +// lock. A variable so tests can shorten it. +var statusReadLockTimeout = 2 * time.Second + +// lockStatusFileForRead is lockStatusFile for the readers, which run inside +// agy's hooks (SnapshotTokenBaseline at PreInvocation, CalculateTokenUsageSince +// at Stop). A hook must not stall behind a tee that hangs while holding the +// lock — the same rule the turn-start session-state lock follows — so the wait +// is bounded, and on timeout the read proceeds unlocked, which is exactly the +// pre-lock behaviour: at worst a torn last line reads as "no snapshot" for one +// turn, against a hook that never returns. The returned release is always +// safe to call. +func lockStatusFileForRead(ctx context.Context, st statusStore, name string) (release func(), err error) { + acqCtx, cancel := context.WithTimeout(ctx, statusReadLockTimeout) + defer cancel() + release, err = flock.AcquireContextIn(acqCtx, st.root, name+statusLockSuffix) + if err == nil { + return release, nil + } + if errors.Is(err, fs.ErrNotExist) { + return nil, err //nolint:wrapcheck // preserved so callers can read it as "no snapshots yet" + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + logging.Debug(logging.WithComponent(ctx, "antigravity"), + "status lock busy; reading token snapshots unlocked", + slog.String("file", name)) + return func() {}, nil + } + return nil, fmt.Errorf("antigravity status: lock: %w", err) +} + +// readLastSnapshotFrom returns the snapshot on the final non-empty line of f, +// or nil if the file has no usable line. It reads a bounded tail window instead +// of streaming the whole file: it is shared by the per-fire dedup comparison +// in AppendStatusSnapshot (on the already-open, locked descriptor) and by +// every-TurnStart SnapshotTokenBaseline, and agy fires the title command on +// each agent state change — a front-to-back scan would cost O(file) per fire, +// O(n^2) over a conversation. +func readLastSnapshotFrom(f *os.File) (*statusSnapshot, error) { + info, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("antigravity status: stat: %w", err) + } + + offset := info.Size() - statusTailWindow + if offset < 0 { + offset = 0 + } + buf := make([]byte, info.Size()-offset) + if _, err := f.ReadAt(buf, offset); err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("antigravity status: read tail: %w", err) + } + + // When the window starts mid-file, the first chunk may be a partial line — + // discard through the first newline so only whole lines are considered. + if offset > 0 { + nl := bytes.IndexByte(buf, '\n') + if nl < 0 { + return nil, nil //nolint:nilnil // single line larger than the window — treat as no usable snapshot + } + buf = buf[nl+1:] + } + + var lastLine []byte + for _, line := range bytes.Split(buf, []byte("\n")) { + if line = bytes.TrimSpace(line); len(line) > 0 { + lastLine = line + } + } + if len(lastLine) == 0 { + return nil, nil //nolint:nilnil // no lines yet — caller handles nil gracefully + } + + var snap statusSnapshot + if err := json.Unmarshal(lastLine, &snap); err != nil { + return nil, nil //nolint:nilerr,nilnil // malformed last line — treat as no prior snapshot + } + return &snap, nil +} + +// readStatusSnapshots reads all valid snapshot lines from the JSONL file for +// the given conversationID. A missing file returns nil, nil (not an error). +func readStatusSnapshots(ctx context.Context, conversationID string) ([]statusSnapshot, error) { + st, err := openStatusStore() + if err != nil { + return nil, err + } + name := st.fileName(conversationID) + if exists, err := snapshotFileExists(st, name); err != nil { + return nil, err + } else if !exists { + return nil, nil // no snapshot yet — and no lock file left behind for it + } + release, err := lockStatusFileForRead(ctx, st, name) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, err + } + defer release() + f, err := osroot.OpenNoFollow(st.root, name) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("antigravity status: open for read: %w", err) + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + + var snaps []statusSnapshot + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + var snap statusSnapshot + if err := json.Unmarshal([]byte(line), &snap); err != nil { + continue // skip malformed lines + } + snaps = append(snaps, snap) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("antigravity status: scan: %w", err) + } + return snaps, nil +} + +// pruneStaleStatusFiles removes other conversations' snapshot files that have +// not been written to within statusRetention, and lock files that are both +// orphaned (no snapshot file beside them) and older than the same cutoff. +// +// A lock file is never pruned while its snapshot file exists: flock does not +// touch mtime, so a lock file is as old as the conversation's first snapshot, +// and a conversation still running past the retention window would otherwise +// have its lock unlinked from under the tee holding it. The next tee would +// create a fresh lock file, lock a different inode, and the dedup read/append +// AppendStatusSnapshot serialises with that lock would race again. +// +// Nor is an orphan pruned on sight: AppendStatusSnapshot takes the lock BEFORE +// it creates the snapshot file, so a prune running from another conversation's +// first append can observe a lock file whose .jsonl does not exist yet. That +// lock is milliseconds old; requiring it to be older than the cutoff leaves it +// alone, while a lock left behind by an earlier prune of its snapshot file is +// at least as old as that file's last write and goes in the same pass. +func pruneStaleStatusFiles(st statusStore, activeConversationID string) { + activePrefix := filepath.Base(activeConversationID) + ".jsonl" + entries, err := osroot.ReadDirNoSymlinks(st.root, st.dirName()) + if err != nil { + return + } + cutoff := time.Now().Add(-statusRetention) + present := make(map[string]bool, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + present[entry.Name()] = true + } + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || strings.HasPrefix(name, activePrefix) || strings.HasSuffix(name, statusLockSuffix) { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + if pruneStaleSnapshot(st, name) { + delete(present, name) + } + } + } + // A lock file whose snapshot file is gone (pruned above, or by an earlier + // run) guards nothing any more — once it is old enough that it cannot be a + // lock another tee is holding while it creates its snapshot file. + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, statusLockSuffix) || strings.HasPrefix(name, activePrefix) { + continue + } + if present[strings.TrimSuffix(name, statusLockSuffix)] { + continue + } + info, err := entry.Info() + if err != nil || !info.ModTime().Before(cutoff) { + continue + } + _ = osroot.RemoveNoSymlinks(st.root, path.Join(st.dir, name)) //nolint:errcheck // best-effort prune + } +} diff --git a/cmd/entire/cli/agent/antigravity/statusline_test.go b/cmd/entire/cli/agent/antigravity/statusline_test.go new file mode 100644 index 0000000000..ecac4825f2 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/statusline_test.go @@ -0,0 +1,969 @@ +package antigravity + +import ( + "context" + "encoding/json" + "errors" + "os" + "path" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// Note: these tests use t.Setenv and/or t.Chdir, so t.Parallel() is not called. + +func TestAppendStatusSnapshot_WritesLineKeyedByConversation(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + payload := []byte(`{"conversation_id":"conv-1","agent_state":"working","context_window":{"total_input_tokens":1000,"total_output_tokens":50,"context_window_size":200000,"current_usage":{"input_tokens":900,"output_tokens":50,"cache_creation_input_tokens":100,"cache_read_input_tokens":800}}}`) + + if err := AppendStatusSnapshot(payload); err != nil { + t.Fatalf("AppendStatusSnapshot: %v", err) + } + + snaps, err := readStatusSnapshots(context.Background(), "conv-1") + if err != nil { + t.Fatalf("readStatusSnapshots: %v", err) + } + if len(snaps) != 1 { + t.Fatalf("got %d snapshots, want 1", len(snaps)) + } + + s := snaps[0] + if s.ContextWindow.TotalInputTokens != 1000 { + t.Errorf("TotalInputTokens = %d, want 1000", s.ContextWindow.TotalInputTokens) + } + if s.ContextWindow.TotalOutputTokens != 50 { + t.Errorf("TotalOutputTokens = %d, want 50", s.ContextWindow.TotalOutputTokens) + } + if s.ContextWindow.CurrentUsage == nil { + t.Fatal("CurrentUsage is nil") + } + if s.ContextWindow.CurrentUsage.CacheReadInputTokens != 800 { + t.Errorf("CacheReadInputTokens = %d, want 800", s.ContextWindow.CurrentUsage.CacheReadInputTokens) + } + if s.Timestamp == "" { + t.Error("Timestamp is empty") + } +} + +func TestAppendStatusSnapshot_DedupsUnchangedContextWindow(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // First payload + p1 := []byte(`{"conversation_id":"conv-2","agent_state":"working","context_window":{"total_input_tokens":1000,"total_output_tokens":50}}`) + // Second payload: same context_window, different agent_state + p2 := []byte(`{"conversation_id":"conv-2","agent_state":"idle","context_window":{"total_input_tokens":1000,"total_output_tokens":50}}`) + // Third payload: different context_window + p3 := []byte(`{"conversation_id":"conv-2","agent_state":"working","context_window":{"total_input_tokens":2000,"total_output_tokens":100}}`) + + for _, p := range [][]byte{p1, p2, p3} { + if err := AppendStatusSnapshot(p); err != nil { + t.Fatalf("AppendStatusSnapshot: %v", err) + } + } + + snaps, err := readStatusSnapshots(context.Background(), "conv-2") + if err != nil { + t.Fatalf("readStatusSnapshots: %v", err) + } + if len(snaps) != 2 { + t.Errorf("got %d snapshots, want 2 (dedup should have skipped p2)", len(snaps)) + } +} + +func TestAppendStatusSnapshot_IgnoresGarbageAndMissingFields(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + for _, payload := range []string{"not json", "{}", `{"conversation_id":"conv-3"}`} { + if err := AppendStatusSnapshot([]byte(payload)); err != nil { + t.Errorf("AppendStatusSnapshot(%q) returned error: %v", payload, err) + } + } + + // dir must be empty (no snapshot files written) + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + if len(entries) != 0 { + t.Errorf("expected empty dir, got %d entries", len(entries)) + } +} + +func TestReadStatusSnapshots_SkipsMalformedLines(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // Write a file manually with valid / garbage / valid lines + validLine1, err := json.Marshal(statusSnapshot{ + Timestamp: "2026-01-01T00:00:00Z", + ConversationID: "conv-4", + ContextWindow: statusContextWindow{TotalInputTokens: 10, TotalOutputTokens: 1}, + }) + if err != nil { + t.Fatal(err) + } + validLine2, err := json.Marshal(statusSnapshot{ + Timestamp: "2026-01-01T00:01:00Z", + ConversationID: "conv-4", + ContextWindow: statusContextWindow{TotalInputTokens: 20, TotalOutputTokens: 2}, + }) + if err != nil { + t.Fatal(err) + } + + filePath := filepath.Join(dir, "conv-4.jsonl") + content := string(validLine1) + "\nGARBAGE LINE\n" + string(validLine2) + "\n" + if err := os.WriteFile(filePath, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + snaps, err := readStatusSnapshots(context.Background(), "conv-4") + if err != nil { + t.Fatalf("readStatusSnapshots: %v", err) + } + if len(snaps) != 2 { + t.Errorf("got %d snapshots, want 2 (malformed line skipped)", len(snaps)) + } +} + +func TestReadStatusSnapshots_MissingFileReturnsEmpty(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + snaps, err := readStatusSnapshots(context.Background(), "no-such-conv") + if err != nil { + t.Fatalf("readStatusSnapshots: %v", err) + } + if len(snaps) != 0 { + t.Errorf("got %d snapshots, want 0", len(snaps)) + } +} + +func BenchmarkAppendStatusSnapshot_GrownFile(b *testing.B) { + dir := b.TempDir() + b.Setenv(statusDirEnv, dir) + + // Seed 500 distinct snapshots + for i := range 500 { + payload, err := json.Marshal(map[string]any{ + "conversation_id": "bench-conv", + "agent_state": "working", + "context_window": map[string]any{ + "total_input_tokens": 1000 + i, + "total_output_tokens": 50 + i, + }, + }) + if err != nil { + b.Fatal(err) + } + if err := AppendStatusSnapshot(payload); err != nil { + b.Fatalf("seed %d: %v", i, err) + } + } + + // The duplicate payload matches the last seeded snapshot (dedup path) + dupPayload, err := json.Marshal(map[string]any{ + "conversation_id": "bench-conv", + "agent_state": "working", + "context_window": map[string]any{ + "total_input_tokens": 1000 + 499, + "total_output_tokens": 50 + 499, + }, + }) + if err != nil { + b.Fatal(err) + } + + b.ResetTimer() + for range b.N { + if err := AppendStatusSnapshot(dupPayload); err != nil { + b.Fatal(err) + } + } +} + +func TestAppendStatusSnapshot_PrunesStaleFilesOnCreate(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // Pre-seed a stale file for another conversation (mtime older than retention) + stale := filepath.Join(dir, "old-conv.jsonl") + if err := os.WriteFile(stale, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-statusRetention - time.Hour) + if err := os.Chtimes(stale, old, old); err != nil { + t.Fatal(err) + } + // And a fresh file for a third conversation that must survive + fresh := filepath.Join(dir, "fresh-conv.jsonl") + if err := os.WriteFile(fresh, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + + // First write for a new conversation triggers the prune + payload := []byte(`{"conversation_id":"conv-new","context_window":{"total_input_tokens":1}}`) + if err := AppendStatusSnapshot(payload); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("stale file should be pruned, stat err = %v", err) + } + if _, err := os.Stat(fresh); err != nil { + t.Errorf("fresh file must survive prune: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "conv-new.jsonl")); err != nil { + t.Errorf("active file must exist: %v", err) + } +} + +// TestAppendStatusSnapshot_PruneKeepsLiveLockFiles: a lock file is as old as +// its conversation's first snapshot (flock never touches mtime), so pruning it +// by age alone would unlink it from under a tee that holds it and let the next +// tee lock a different inode — the dedup race the lock exists to close. A lock +// file is pruned only once its snapshot file is gone AND it is older than the +// retention cutoff: the first rule keeps long-lived conversations' locks, the +// second keeps a lock another tee has just taken but not yet written beside. +func TestAppendStatusSnapshot_PruneKeepsLiveLockFiles(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + old := time.Now().Add(-statusRetention - time.Hour) + + // A long-lived conversation: snapshot file written recently, lock file + // created long ago. + live := filepath.Join(dir, "live-conv.jsonl") + if err := os.WriteFile(live, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + liveLock := live + statusLockSuffix + if err := os.WriteFile(liveLock, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(liveLock, old, old); err != nil { + t.Fatal(err) + } + + // A finished conversation: both files stale. + gone := filepath.Join(dir, "gone-conv.jsonl") + if err := os.WriteFile(gone, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + goneLock := gone + statusLockSuffix + if err := os.WriteFile(goneLock, nil, 0o600); err != nil { + t.Fatal(err) + } + for _, p := range []string{gone, goneLock} { + if err := os.Chtimes(p, old, old); err != nil { + t.Fatal(err) + } + } + + // An orphaned lock file left by an earlier prune run, long ago. + orphanLock := filepath.Join(dir, "orphan-conv.jsonl"+statusLockSuffix) + if err := os.WriteFile(orphanLock, nil, 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(orphanLock, old, old); err != nil { + t.Fatal(err) + } + + // Another conversation's first tee, caught between taking its lock and + // creating its snapshot file: a fresh lock with no .jsonl beside it yet. + // Unlinking it here would leave that tee holding a lock on a dead inode. + inFlightLock := filepath.Join(dir, "inflight-conv.jsonl"+statusLockSuffix) + if err := os.WriteFile(inFlightLock, nil, 0o600); err != nil { + t.Fatal(err) + } + + payload := []byte(`{"conversation_id":"conv-new","context_window":{"total_input_tokens":1}}`) + if err := AppendStatusSnapshot(payload); err != nil { + t.Fatal(err) + } + + for _, p := range []string{liveLock, inFlightLock} { + if _, err := os.Stat(p); err != nil { + t.Errorf("%s must survive the prune: %v", filepath.Base(p), err) + } + } + for _, p := range []string{gone, goneLock, orphanLock} { + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("%s should be pruned, stat err = %v", filepath.Base(p), err) + } + } +} + +// writeSnapshotFixture writes the given snapshots as JSONL to the snapshot file +// for conversationID, using the statusDirEnv override already set by the test. +func writeSnapshotFixture(t *testing.T, conversationID string, snaps []statusSnapshot) { + t.Helper() + path, err := statusFilePath(conversationID) + if err != nil { + t.Fatalf("statusFilePath: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + var buf []byte + for _, s := range snaps { + line, marshalErr := json.Marshal(s) + if marshalErr != nil { + t.Fatalf("marshal snapshot: %v", marshalErr) + } + buf = append(buf, line...) + buf = append(buf, '\n') + } + if err := os.WriteFile(path, buf, 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } +} + +func TestCalculateTokenUsageSince_DeltaFromBaseline(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + snaps := []statusSnapshot{ + { + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 1000, + TotalOutputTokens: 100, + CurrentUsage: &statusCurrentUsage{CacheCreationInputTokens: 200, CacheReadInputTokens: 700}, + }, + }, + { + Timestamp: "2026-06-03T10:05:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 3000, + TotalOutputTokens: 250, + CurrentUsage: &statusCurrentUsage{CacheCreationInputTokens: 50, CacheReadInputTokens: 1900}, + }, + }, + { + Timestamp: "2026-06-03T10:06:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 4500, + TotalOutputTokens: 400, + CurrentUsage: &statusCurrentUsage{CacheCreationInputTokens: 0, CacheReadInputTokens: 1500}, + }, + }, + } + writeSnapshotFixture(t, "c1", snaps) + + baseline, err := json.Marshal(snaps[0]) + if err != nil { + t.Fatalf("marshal baseline: %v", err) + } + + a := &AntigravityAgent{} + usage, err := a.CalculateTokenUsageSince(context.Background(), "c1", baseline) + if err != nil { + t.Fatalf("CalculateTokenUsageSince: %v", err) + } + if usage == nil { + t.Fatal("usage is nil") + } + if usage.InputTokens != 3500 { + t.Errorf("InputTokens = %d, want 3500", usage.InputTokens) + } + if usage.OutputTokens != 300 { + t.Errorf("OutputTokens = %d, want 300", usage.OutputTokens) + } + if usage.CacheCreationTokens != 50 { + t.Errorf("CacheCreationTokens = %d, want 50", usage.CacheCreationTokens) + } + if usage.CacheReadTokens != 3400 { + t.Errorf("CacheReadTokens = %d, want 3400", usage.CacheReadTokens) + } + if usage.APICallCount != 2 { + t.Errorf("APICallCount = %d, want 2", usage.APICallCount) + } +} + +func TestCalculateTokenUsageSince_NilBaselineCountsFromZero(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + snaps := []statusSnapshot{ + { + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c2", + ContextWindow: statusContextWindow{ + TotalInputTokens: 1000, + TotalOutputTokens: 100, + CurrentUsage: &statusCurrentUsage{CacheReadInputTokens: 700}, + }, + }, + } + writeSnapshotFixture(t, "c2", snaps) + + a := &AntigravityAgent{} + usage, err := a.CalculateTokenUsageSince(context.Background(), "c2", nil) + if err != nil { + t.Fatalf("CalculateTokenUsageSince: %v", err) + } + if usage == nil { + t.Fatal("usage is nil") + } + if usage.InputTokens != 1000 { + t.Errorf("InputTokens = %d, want 1000", usage.InputTokens) + } + if usage.OutputTokens != 100 { + t.Errorf("OutputTokens = %d, want 100", usage.OutputTokens) + } +} + +func TestCalculateTokenUsageSince_NoDataReturnsNilNil(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + a := &AntigravityAgent{} + usage, err := a.CalculateTokenUsageSince(context.Background(), "missing-conv", nil) + if err != nil { + t.Fatalf("CalculateTokenUsageSince: %v", err) + } + if usage != nil { + t.Errorf("usage = %+v, want nil", usage) + } +} + +func TestSnapshotTokenBaseline_ReturnsLatestSnapshot(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + snaps := []statusSnapshot{ + { + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c3", + ContextWindow: statusContextWindow{TotalInputTokens: 1000}, + }, + { + Timestamp: "2026-06-03T10:05:00.000000000Z", + ConversationID: "c3", + ContextWindow: statusContextWindow{TotalInputTokens: 2000}, + }, + } + writeSnapshotFixture(t, "c3", snaps) + + a := &AntigravityAgent{} + baseline, err := a.SnapshotTokenBaseline(context.Background(), "c3") + if err != nil { + t.Fatalf("SnapshotTokenBaseline: %v", err) + } + if len(baseline) == 0 { + t.Fatal("baseline is empty") + } + var snap statusSnapshot + if err := json.Unmarshal(baseline, &snap); err != nil { + t.Fatalf("unmarshal baseline: %v", err) + } + if snap.ContextWindow.TotalInputTokens != 2000 { + t.Errorf("baseline TotalInputTokens = %d, want 2000", snap.ContextWindow.TotalInputTokens) + } +} + +func TestSnapshotTokenBaseline_EmptyStoreReturnsNil(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + a := &AntigravityAgent{} + baseline, err := a.SnapshotTokenBaseline(context.Background(), "no-such-conv") + if err != nil { + t.Fatalf("SnapshotTokenBaseline: %v", err) + } + if baseline != nil { + t.Errorf("baseline = %v, want nil", baseline) + } +} + +func TestCalculateTokenUsageSince_ClampsWhenTotalsGoBackwards(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // Simulate conversation-id reuse where cumulative totals reset lower than the baseline. + snaps := []statusSnapshot{ + { + Timestamp: "2026-06-03T10:05:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{TotalInputTokens: 500, TotalOutputTokens: 30}, + }, + } + writeSnapshotFixture(t, "c1", snaps) + + // Baseline has higher totals than the latest snapshot — totals went backwards. + baseSnap := statusSnapshot{ + Timestamp: "2026-06-03T10:00:00.000000000Z", + ContextWindow: statusContextWindow{TotalInputTokens: 5000, TotalOutputTokens: 400}, + } + baseline, err := json.Marshal(baseSnap) + if err != nil { + t.Fatalf("marshal baseline: %v", err) + } + + a := &AntigravityAgent{} + usage, err := a.CalculateTokenUsageSince(context.Background(), "c1", baseline) + if err != nil { + t.Fatalf("CalculateTokenUsageSince: %v", err) + } + // The single line has no current_usage, so once max(0, ...) clamps the + // negative input/output deltas to zero, every field is zero and the method + // returns (nil, nil) — the all-zero "nothing observed" path. If the clamp + // were removed, the negative deltas would make usage non-nil, so asserting + // nil here pins the clamp behavior directly. + if usage != nil { + t.Errorf("usage = %+v, want nil (negative deltas clamped to zero -> all-zero -> nil)", usage) + } +} + +func TestCalculateTokenUsageSince_UnparseableBaselineCountsAllLines(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + snaps := []statusSnapshot{ + { + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 1000, + TotalOutputTokens: 100, + CurrentUsage: &statusCurrentUsage{CacheReadInputTokens: 700}, + }, + }, + { + Timestamp: "2026-06-03T10:05:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 3000, + TotalOutputTokens: 250, + CurrentUsage: &statusCurrentUsage{CacheReadInputTokens: 900}, + }, + }, + } + writeSnapshotFixture(t, "c1", snaps) + + // Baseline with an unparseable timestamp — documents accepted degradation: + // input/output stay exact via the totals subtraction, but cache/apicall + // counts cover all lines rather than only post-baseline lines. + baseline := json.RawMessage(`{"ts":"not-a-timestamp","context_window":{"total_input_tokens":1000,"total_output_tokens":100}}`) + + a := &AntigravityAgent{} + usage, err := a.CalculateTokenUsageSince(context.Background(), "c1", baseline) + if err != nil { + t.Fatalf("CalculateTokenUsageSince: %v", err) + } + if usage == nil { + t.Fatal("usage is nil") + } + if usage.InputTokens != 2000 { + t.Errorf("InputTokens = %d, want 2000 (3000-1000)", usage.InputTokens) + } + if usage.OutputTokens != 150 { + t.Errorf("OutputTokens = %d, want 150 (250-100)", usage.OutputTokens) + } + // Both lines are counted because the baseline timestamp didn't parse. + if usage.APICallCount != 2 { + t.Errorf("APICallCount = %d, want 2 (all lines counted)", usage.APICallCount) + } + if usage.CacheReadTokens != 1600 { + t.Errorf("CacheReadTokens = %d, want 1600 (700+900)", usage.CacheReadTokens) + } +} + +// TestAppendStatusSnapshot_CurrentUsageChangeIsNotDeduped proves that two +// payloads with IDENTICAL total_input_tokens/total_output_tokens but DIFFERENT +// current_usage are NOT deduped: both must be persisted. The delta calculation +// sums per-line cache fields from current_usage, so collapsing two lines that +// differ only in current_usage would silently drop cache accounting. +func TestAppendStatusSnapshot_CurrentUsageChangeIsNotDeduped(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // Same totals {1000,100}, different current_usage cache_read. + a := []byte(`{"conversation_id":"conv-cu","agent_state":"working","context_window":{"total_input_tokens":1000,"total_output_tokens":100,"current_usage":{"cache_read_input_tokens":700}}}`) + b := []byte(`{"conversation_id":"conv-cu","agent_state":"working","context_window":{"total_input_tokens":1000,"total_output_tokens":100,"current_usage":{"cache_read_input_tokens":900}}}`) + + for _, p := range [][]byte{a, b} { + if err := AppendStatusSnapshot(p); err != nil { + t.Fatalf("AppendStatusSnapshot: %v", err) + } + } + + snaps, err := readStatusSnapshots(context.Background(), "conv-cu") + if err != nil { + t.Fatalf("readStatusSnapshots: %v", err) + } + if len(snaps) != 2 { + t.Fatalf("got %d snapshots, want 2 (current_usage change must not be deduped)", len(snaps)) + } +} + +// TestCalculateTokenUsageSince_NoDoubleCountWhenBaselineIsLatest proves the +// load-bearing strictly-.After filter prevents double-counting across turns. +// Simulating turn N+1: use turn N's LATEST snapshot as the baseline and append +// nothing new. CalculateTokenUsageSince must return (nil, nil) — zero delta, +// APICallCount 0 — because no snapshot is strictly after the baseline timestamp +// and totals − baseline = 0. +func TestCalculateTokenUsageSince_NoDoubleCountWhenBaselineIsLatest(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + snaps := []statusSnapshot{ + { + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 1000, + TotalOutputTokens: 100, + CurrentUsage: &statusCurrentUsage{CacheCreationInputTokens: 200, CacheReadInputTokens: 700}, + }, + }, + { + Timestamp: "2026-06-03T10:05:00.000000000Z", + ConversationID: "c1", + ContextWindow: statusContextWindow{ + TotalInputTokens: 3000, + TotalOutputTokens: 250, + CurrentUsage: &statusCurrentUsage{CacheCreationInputTokens: 50, CacheReadInputTokens: 1900}, + }, + }, + } + writeSnapshotFixture(t, "c1", snaps) + + a := &AntigravityAgent{} + + // Turn N: full usage from nil baseline. + full, err := a.CalculateTokenUsageSince(context.Background(), "c1", nil) + if err != nil { + t.Fatalf("CalculateTokenUsageSince (full): %v", err) + } + if full == nil || full.InputTokens != 3000 { + t.Fatalf("full usage = %+v, want InputTokens 3000", full) + } + + // Capture the latest snapshot (T2 line) exactly as the lifecycle does. + baseline, err := a.SnapshotTokenBaseline(context.Background(), "c1") + if err != nil { + t.Fatalf("SnapshotTokenBaseline: %v", err) + } + if len(baseline) == 0 { + t.Fatal("baseline is empty") + } + + // Turn N+1: nothing new appended. Delta from the latest baseline is zero. + usage, err := a.CalculateTokenUsageSince(context.Background(), "c1", baseline) + if err != nil { + t.Fatalf("CalculateTokenUsageSince (delta): %v", err) + } + if usage != nil { + t.Errorf("usage = %+v, want nil (no snapshot strictly after baseline; zero delta)", usage) + } +} + +// Concurrent tees are the norm (agy fires the title command on every state +// change without serializing), and the dedup read and the append must be one +// critical section: without the lock a second invocation could append between +// them and the duplicate would land. +func TestAppendStatusSnapshot_ConcurrentDuplicatesCollapseToOneLine(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + payload := []byte(`{"conversation_id":"conv-race","context_window":{"total_input_tokens":4242,"total_output_tokens":17}}`) + const writers = 16 + var wg sync.WaitGroup + errs := make(chan error, writers) + for range writers { + wg.Add(1) + go func() { + defer wg.Done() + errs <- AppendStatusSnapshot(payload) + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("AppendStatusSnapshot: %v", err) + } + } + + snaps, err := readStatusSnapshots(context.Background(), "conv-race") + if err != nil { + t.Fatalf("readStatusSnapshots: %v", err) + } + if len(snaps) != 1 { + t.Fatalf("got %d snapshot lines for identical concurrent payloads, want exactly 1", len(snaps)) + } +} + +// The store is a root anchor: a symlink planted where a snapshot file belongs is +// refused by every reader and writer, never followed. +func TestStatusStore_RefusesSymlinkedSnapshotFile(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + target := filepath.Join(t.TempDir(), "elsewhere.jsonl") + if err := os.WriteFile(target, []byte(`{"ts":"2026-01-01T00:00:00Z","conversation_id":"conv-link","context_window":{"total_input_tokens":1}}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(dir, "conv-link.jsonl")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + if snaps, err := readStatusSnapshots(context.Background(), "conv-link"); err == nil { + t.Fatalf("readStatusSnapshots followed a symlink: got %d snapshots, want an error", len(snaps)) + } + payload := []byte(`{"conversation_id":"conv-link","context_window":{"total_input_tokens":2}}`) + if err := AppendStatusSnapshot(payload); err == nil { + t.Fatal("AppendStatusSnapshot wrote through a symlink, want an error") + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if strings.Count(string(data), "\n") != 1 { + t.Fatalf("symlink target was modified:\n%s", data) + } +} + +// TestReaders_TakeTheConversationLock: SnapshotTokenBaseline and +// CalculateTokenUsageSince read under the same per-conversation lock the tee +// appends under, so a baseline or delta read cannot observe a torn last line +// from a concurrent append and treat it as "no snapshot". Pinned by holding the +// lock externally and watching the reader wait for it. +func TestReaders_TakeTheConversationLock(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + writeSnapshotFixture(t, "c-lock", []statusSnapshot{{ + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c-lock", + ContextWindow: statusContextWindow{TotalInputTokens: 1}, + }}) + + st, err := openStatusStore() + if err != nil { + t.Fatal(err) + } + release, err := lockStatusFile(st, st.fileName("c-lock")) + if err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { + a := &AntigravityAgent{} + baseline, err := a.SnapshotTokenBaseline(context.Background(), "c-lock") + if err != nil { + done <- err + return + } + if len(baseline) == 0 { + done <- errors.New("SnapshotTokenBaseline returned no baseline for a conversation with a snapshot") + return + } + _, err = a.CalculateTokenUsageSince(context.Background(), "c-lock", nil) + done <- err + }() + + select { + case <-done: + t.Fatal("readers completed while another process held the conversation lock") + case <-time.After(200 * time.Millisecond): + } + release() + select { + case err := <-done: + if err != nil { + t.Fatalf("readers after the lock was released: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("readers did not proceed after the lock was released") + } +} + +// TestReaders_DegradeWhenTheLockIsStuck: the readers run inside agy's hooks, +// so a tee that hangs while holding the conversation lock must not hang the +// hook. The wait is bounded; on timeout the read proceeds unlocked (the +// pre-lock behaviour) instead of blocking the turn. +func TestReaders_DegradeWhenTheLockIsStuck(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + writeSnapshotFixture(t, "c-stuck", []statusSnapshot{{ + Timestamp: "2026-06-03T10:00:00.000000000Z", + ConversationID: "c-stuck", + ContextWindow: statusContextWindow{TotalInputTokens: 7}, + }}) + + prev := statusReadLockTimeout + statusReadLockTimeout = 100 * time.Millisecond + t.Cleanup(func() { statusReadLockTimeout = prev }) + + st, err := openStatusStore() + if err != nil { + t.Fatal(err) + } + release, err := lockStatusFile(st, st.fileName("c-stuck")) + if err != nil { + t.Fatal(err) + } + defer release() // held for the whole test: the "stuck tee" + + done := make(chan error, 1) + go func() { + baseline, err := (&AntigravityAgent{}).SnapshotTokenBaseline(context.Background(), "c-stuck") + if err != nil { + done <- err + return + } + if len(baseline) == 0 { + done <- errors.New("SnapshotTokenBaseline degraded to no baseline instead of reading unlocked") + return + } + done <- nil + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("reader hung behind a stuck lock holder; the wait must be bounded") + } +} + +// Without the override the store lives at /antigravity/status: a +// two-segment name that must be slash-separated for osroot to create it. Every +// other test here runs under ENTIRE_ANTIGRAVITY_STATUS_DIR, where the dir is +// "" and the mkdir is skipped — which is how a filepath.Join here shipped. +func TestStatusStore_DefaultDirIsCreatedUnderTheCacheRoot(t *testing.T) { + cache := t.TempDir() + t.Setenv(statusDirEnv, "") + t.Setenv("XDG_CACHE_HOME", cache) + + payload := []byte(`{"conversation_id":"conv-default","context_window":{"total_input_tokens":7,"total_output_tokens":1}}`) + if err := AppendStatusSnapshot(payload); err != nil { + t.Fatalf("AppendStatusSnapshot: %v", err) + } + want := filepath.Join(cache, "entire", "antigravity", "status", "conv-default.jsonl") + if _, err := os.Stat(want); err != nil { + t.Fatalf("snapshot file not created under the cache root: %v", err) + } + snaps, err := readStatusSnapshots(context.Background(), "conv-default") + if err != nil || len(snaps) != 1 { + t.Fatalf("readStatusSnapshots = %d snapshots, %v; want 1", len(snaps), err) + } +} + +// A read of a conversation that has no snapshot must not leave a lock file +// behind: flock.AcquireIn creates the lock it opens, and every TurnStart takes +// a baseline, so conversations that never produce a snapshot would otherwise +// litter the store with orphans for the whole retention window. +func TestStatusReaders_LeaveNoLockFileForAnUnknownConversation(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + a := &AntigravityAgent{} + + raw, err := a.SnapshotTokenBaseline(context.Background(), "conv-never") + if err != nil || raw != nil { + t.Fatalf("SnapshotTokenBaseline = %q, %v; want nil, nil", raw, err) + } + usage, err := a.CalculateTokenUsageSince(context.Background(), "conv-never", nil) + if err != nil || usage != nil { + t.Fatalf("CalculateTokenUsageSince = %v, %v; want nil, nil", usage, err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), statusLockSuffix) { + t.Fatalf("reader left an orphan lock file %s behind", e.Name()) + } + } +} + +// A dormant conversation resumed between the prune's stat and its unlink is +// mid-append, and removing the file from under its open fd loses the per-line +// detail CalculateTokenUsageSince derives between the baseline and now. The +// prune takes the conversation's own lock first, so a held lock defers the +// delete to the next run — the same care the lock-file loop already takes. +func TestAppendStatusSnapshot_PruneSkipsALockedStaleFile(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // Stale by mtime, so the prune would otherwise remove it. + stale := filepath.Join(dir, "resumed-conv.jsonl") + if err := os.WriteFile(stale, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-statusRetention - time.Hour) + if err := os.Chtimes(stale, old, old); err != nil { + t.Fatal(err) + } + + // Stand in for the tee that just resumed it and holds its lock. + st, err := openStatusStore() + if err != nil { + t.Fatal(err) + } + release, err := lockStatusFile(st, path.Join(st.dir, "resumed-conv.jsonl")) + if err != nil { + t.Fatalf("take the conversation lock: %v", err) + } + defer release() + + // First write for a different conversation triggers the prune. + payload := []byte(`{"conversation_id":"conv-new","context_window":{"total_input_tokens":1}}`) + if err := AppendStatusSnapshot(payload); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(stale); err != nil { + t.Errorf("a stale file whose conversation holds its lock must survive the prune: %v", err) + } +} + +// The try-lock is not a free probe: flock opens with O_CREATE|O_EXCL, so asking +// for a lock that does not exist creates one. Doing that while pruning would +// leave an orphan lock behind for every conversation deleted — the orphan-lock +// loop cannot collect it in the same pass, and on the next it must age past the +// cutoff first, so each deletion leaks a file for another retention window. +// A conversation with no lock file has never had a tee mid-append, since the +// lock is taken before the .jsonl is created, so it is unlinked directly. +func TestAppendStatusSnapshot_PruneLeavesNoOrphanLockBehind(t *testing.T) { + dir := t.TempDir() + t.Setenv(statusDirEnv, dir) + + // Stale, and deliberately without a lock file beside it. + stale := filepath.Join(dir, "lockless-conv.jsonl") + if err := os.WriteFile(stale, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-statusRetention - time.Hour) + if err := os.Chtimes(stale, old, old); err != nil { + t.Fatal(err) + } + + payload := []byte(`{"conversation_id":"conv-new","context_window":{"total_input_tokens":1}}`) + if err := AppendStatusSnapshot(payload); err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("a stale file with no lock must still be pruned, stat err = %v", err) + } + if _, err := os.Stat(stale + statusLockSuffix); !os.IsNotExist(err) { + t.Error("pruning created a lock file for the conversation it deleted") + } +} 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": ["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/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": "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/workspace/project", + "WaitMsBeforeAsync": 5000 + } + }, + "stepIdx": 19, + "conversationId": "ec33ebf9-0cba-4100-8142-c61503f6c587", + "workspacePaths": ["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/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": ["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/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/title_install.go b/cmd/entire/cli/agent/antigravity/title_install.go new file mode 100644 index 0000000000..95c40da42a --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/title_install.go @@ -0,0 +1,401 @@ +package antigravity + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/internal/flock" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/osroot" + "github.com/entireio/cli/internal/entireclient/userdirs" +) + +// agy reads its window-title command from the GLOBAL config +// ~/.gemini/antigravity-cli/settings.json — a single slot: +// +// {"title": {"type": "command", "command": ""}} +// +// We occupy that slot with the title-tee shim (the title script receives the +// same state JSON as the statusline script — agy's only token-usage surface). +// A pre-existing user command is preserved INSIDE the shim invocation — via +// --wrap '' where agy runs the slot through sh, or --wrap-b64 +// where it runs it through cmd.exe — making the config +// self-describing: uninstall restores the original without any backup file. Because the slot is global, per-repo +// `entire disable` does NOT uninstall it (other repos may rely on it); only +// agent removal does. + +// configDirEnv overrides the agy config directory (tests). +const configDirEnv = "ENTIRE_ANTIGRAVITY_CONFIG_DIR" + +const titleTeeMarker = "hooks antigravity title-tee" + +type titleConfig struct { + Type string `json:"type"` + Command string `json:"command"` +} + +// agySettingsFileName is agy's global settings file inside its config dir. +const agySettingsFileName = "settings.json" + +// agyConfigDir returns the agy config directory, honouring the override env var. +func agyConfigDir() (string, error) { + if dir := os.Getenv(configDirEnv); dir != "" { + // The same absolute-path rule the statusline override and userdirs' + // own overrides are held to, from the one helper that states it. + if err := userdirs.RequireAbsoluteOverride(configDirEnv, dir); err != nil { + return "", err //nolint:wrapcheck // the helper already names the variable + } + return dir, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to resolve home dir: %w", err) + } + return filepath.Join(home, ".gemini", "antigravity-cli"), nil +} + +// openAgyConfigRoot opens an *os.Root over agy's config directory so +// settings.json is read and written as a NAME inside it, never through a +// symlink (docs/development/filesystem-safety.md). The directory is agy's own, +// resolved from HOME (or the operator override), so it is the trusted base; +// with create it is made first — the root is the directory itself, so it +// cannot be created through it. The caller closes the root: this is not one of +// the process-wide anchors, and the override changes between tests. +func openAgyConfigRoot(create bool) (*os.Root, error) { + dir, err := agyConfigDir() + if err != nil { + return nil, err + } + if create { + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, fmt.Errorf("failed to create agy config dir: %w", err) + } + } + root, err := os.OpenRoot(dir) + if err != nil { + return nil, err //nolint:wrapcheck // preserved for errors.Is(err, fs.ErrNotExist) at call sites + } + return root, nil +} + +// titleTeeCommand returns the full shell command string for the title-tee shim. +// If original is non-empty, the original command is wrapped via --wrap. +// +// The command always names the `entire` binary (resolved via $PATH): the title +// slot lives in agy's GLOBAL settings.json and is invoked from whatever +// directory agy runs in, so it must never depend on a repository path. +func titleTeeCommand(original string) string { + base := "entire hooks antigravity title-tee" + if original == "" { + return base + } + if agent.HookHostIsWindows() { + // agy hands the slot to cmd.exe on Windows, where POSIX single quotes + // are literal characters and the tee has no sh to re-run the original + // with. The original travels base64url-encoded instead — an alphabet + // ([A-Za-z0-9_-], no padding) no shell touches — and the tee runs it + // through cmd.exe itself, exactly as agy would have. + return base + " " + strings.TrimSpace(wrapB64Flag) + " " + base64.RawURLEncoding.EncodeToString([]byte(original)) + } + return base + " --wrap " + shellSingleQuote(original) +} + +// wrapFlag and wrapB64Flag are the two spellings of a preserved original +// command inside a tee command string, each surrounded by spaces so that an +// original that merely CONTAINS the text (see +// TestInstallTitle_WrapsCommandContainingWrapSubstring) cannot be mistaken +// for the flag. +const ( + wrapFlag = " --wrap " + wrapB64Flag = " --wrap-b64 " +) + +// shellSingleQuote wraps s in POSIX single quotes. Embedded single quotes are +// rewritten with the standard close-escape-reopen technique (see the +// strings.ReplaceAll below) so the result is safe inside a single-quoted shell +// argument. +// +// Trust boundary: s is only ever the title command the user already configured +// in agy's own global settings.json — a command agy runs verbatim, as the +// user, on every state change. Quoting it here changes nothing about what it +// may do; it only guarantees that agy hands it to `entire ... --wrap` as ONE +// argument, so that title-tee later re-executes exactly the command that was +// there (via `sh -c`, the same shell agy would have used) and uninstall can +// restore it byte for byte. No content from a repository, a hook payload, or +// any other party reaches this function, and `entire` never composes a shell +// command from anything but that user-authored string. +func shellSingleQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// lockAgySettings serialises the read-modify-write of agy's global +// settings.json across entire processes: two `entire agent add antigravity` +// runs from different repos on one machine (or an add racing a remove) would +// otherwise both read the same slot, and the second atomic write would replace +// the first's, leaving the title slot wrapped twice, unwrapped, or restored to +// the wrong original with no error anywhere. The lock file sits beside +// settings.json, like the status store's beside its snapshot file. With create +// the config directory is made first; without it a missing directory is +// reported through errors.Is(err, fs.ErrNotExist) for callers that then have +// nothing to do. errors.Is rather than os.IsNotExist because it keeps working +// if any layer below starts wrapping: openAgyConfigRoot and the osroot helpers +// currently return ENOENT unwrapped on purpose, which os.IsNotExist depends on. +func lockAgySettings(create bool) (release func(), err error) { + root, err := openAgyConfigRoot(create) + if err != nil { + return nil, err + } + defer root.Close() + release, err = flock.AcquireIn(root, agySettingsFileName+statusLockSuffix) + if err != nil { + return nil, fmt.Errorf("failed to lock agy settings: %w", err) + } + return release, nil +} + +// InstallTitleTee installs the title-tee shim into agy's global settings.json. +// If a user's own title command is already present, it is preserved via --wrap. +// The call is idempotent: if our marker is already in the command, it returns nil. +func InstallTitleTee() error { + release, err := lockAgySettings(true) + if err != nil { + return err + } + defer release() + + rawFile, err := readAgySettings() + if err != nil { + return err + } + + // Parse existing title entry (if any). Unparseable → treat as absent. + var existing titleConfig + if raw, ok := rawFile["title"]; ok { + _ = json.Unmarshal(raw, &existing) //nolint:errcheck // treat unparseable as absent + } + + // Idempotency: already contains our marker. + if strings.Contains(existing.Command, titleTeeMarker) { + return nil + } + + // Build new title config, wrapping any pre-existing command. + cfg := titleConfig{ + Type: hookTypeCommand, + Command: titleTeeCommand(existing.Command), + } + + cfgBytes, err := jsonutil.MarshalWithNoHTMLEscape(cfg) + if err != nil { + return fmt.Errorf("failed to marshal title config: %w", err) + } + rawFile["title"] = cfgBytes + + return writeAgySettings(rawFile) +} + +// TitleTeeInstalled reports whether agy's global settings.json declares a +// title command containing the title-tee marker. It is used by `entire doctor` +// to warn when Antigravity hooks are installed in a repo but the global title +// slot — agy's only token-usage surface — has not been claimed, which would +// leave token counts missing from checkpoints. A missing or unparseable +// settings file reports false. +func TitleTeeInstalled() bool { + rawFile, err := readAgySettings() + if err != nil { + return false + } + + raw, ok := rawFile["title"] + if !ok { + return false + } + + var existing titleConfig + if err := json.Unmarshal(raw, &existing); err != nil { + return false + } + return strings.Contains(existing.Command, titleTeeMarker) +} + +// UninstallTitleTee removes or restores the title entry in agy's global settings.json: +// - bare tee (no --wrap) → delete "title" key +// - tee with --wrap 'X' → restore X +// - any other (foreign) cmd → leave untouched +// - missing settings file → no-op +func UninstallTitleTee() error { + // A missing config directory means agy never ran here: nothing to + // uninstall, and no reason to create the directory just to lock in it. + release, err := lockAgySettings(false) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err + } + defer release() + + // A missing settings file reads as an empty map, and an empty map has no + // title key, so there is nothing to uninstall; a symlinked settings.json is + // refused by the read, exactly as install refuses it. + rawFile, err := readAgySettings() + if err != nil { + return err + } + + raw, ok := rawFile["title"] + if !ok { + return nil // no title key — nothing to do + } + + var existing titleConfig + if err := json.Unmarshal(raw, &existing); err != nil { + return nil // unparseable title entry — leave it alone rather than destroying user data + } + + // Not our command → leave untouched. + if !strings.Contains(existing.Command, titleTeeMarker) { + return nil + } + + wrapped, hasWrap := extractWrappedCommand(existing.Command) + if hasWrap { + // Restore the original command. + restored := titleConfig{ + Type: hookTypeCommand, + Command: wrapped, + } + restoredBytes, err := jsonutil.MarshalWithNoHTMLEscape(restored) + if err != nil { + return fmt.Errorf("failed to marshal restored title config: %w", err) + } + rawFile["title"] = restoredBytes + } else { + // Only delete the key when the command has the shape of a bare tee we + // would have written ourselves. Anything else containing the marker is + // a user-authored wrapper (e.g. "my-wrapper.sh 'entire hooks + // antigravity title-tee'") or a corrupted command — leaving it alone + // is always safer than deleting the user's config. + if !isBareTitleTeeCommand(existing.Command) { + return nil + } + delete(rawFile, "title") + } + + return writeAgySettings(rawFile) +} + +// isBareTitleTeeCommand reports whether command is one of the bare (no --wrap) +// tee commands any Entire install could have written: the production form, or +// the legacy local-dev form `go run '/cmd/entire/main.go' hooks +// antigravity title-tee` that older versions wrote (local-dev mode was removed +// in a9a676e79). The legacy form is matched by SHAPE, from ANY repo/worktree: +// uninstall may run from a different worktree (or outside a repo) than install +// did, and an exact-path comparison would silently orphan the global entry, +// leaving agy to spawn a failing `go run` on every state change after the +// original worktree is deleted. +func isBareTitleTeeCommand(command string) bool { + if command == titleTeeCommand("") { + return true + } + return strings.HasPrefix(command, "go run ") && + strings.HasSuffix(command, " hooks antigravity title-tee") +} + +// extractWrappedCommand parses the preserved original out of a title-tee +// command string: the --wrap '' form, or the --wrap-b64 +// form written on Windows hosts. It returns the original command and true if +// found and valid, or ("", false) otherwise. The quoted form is tried first: +// it is the only one whose payload can itself contain either flag's text. +func extractWrappedCommand(command string) (string, bool) { + idx := strings.Index(command, wrapFlag) + if idx < 0 { + return extractBase64WrappedCommand(command) + } + rest := strings.TrimSpace(command[idx+len(wrapFlag):]) + if len(rest) < 2 || rest[0] != '\'' || rest[len(rest)-1] != '\'' { + return "", false + } + // Strip outer single quotes and reverse the '\'' escaping. + inner := rest[1 : len(rest)-1] + return strings.ReplaceAll(inner, `'\''`, "'"), true +} + +// extractBase64WrappedCommand parses the --wrap-b64 form. A token that +// is anything but one base64url word, or that decodes to nothing, is treated +// as malformed — uninstall then leaves the entry alone, as it does for a +// malformed quoted form. +func extractBase64WrappedCommand(command string) (string, bool) { + idx := strings.Index(command, wrapB64Flag) + if idx < 0 { + return "", false + } + token := strings.TrimSpace(command[idx+len(wrapB64Flag):]) + if token == "" || strings.ContainsAny(token, " \t") { + return "", false + } + decoded, err := base64.RawURLEncoding.DecodeString(token) + if err != nil || len(decoded) == 0 { + return "", false + } + return string(decoded), true +} + +// readAgySettings reads and parses settings.json into a raw map. +// A missing directory or file returns an empty map (not an error); a +// symlinked settings.json is refused rather than read through. +func readAgySettings() (map[string]json.RawMessage, error) { + rawFile := make(map[string]json.RawMessage) + root, err := openAgyConfigRoot(false) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return rawFile, nil + } + return nil, fmt.Errorf("failed to open agy config dir: %w", err) + } + defer root.Close() + data, err := osroot.ReadFileNoFollow(root, agySettingsFileName) + if errors.Is(err, fs.ErrNotExist) { + return rawFile, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read agy settings: %w", err) + } + if err := json.Unmarshal(data, &rawFile); err != nil { + return nil, fmt.Errorf("failed to parse agy settings: %w", err) + } + return rawFile, nil +} + +// writeAgySettings marshals rawFile and writes settings.json atomically inside +// agy's config dir, creating the dir as needed. settings.json is +// machine-global (its title slot is shared by every repo on the machine), so a +// crash mid-write must not truncate it, and a symlink at the file is refused +// rather than replaced. +func writeAgySettings(rawFile map[string]json.RawMessage) error { + output, err := jsonutil.MarshalIndentWithNewline(rawFile, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal agy settings: %w", err) + } + root, err := openAgyConfigRoot(true) + if err != nil { + return fmt.Errorf("failed to open agy config dir: %w", err) + } + defer root.Close() + if info, err := osroot.LstatNoSymlinks(root, agySettingsFileName); err == nil && info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("failed to write agy settings: %w", osroot.ErrSymlinkedPath) + } else if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("failed to inspect agy settings: %w", err) + } + if err := jsonutil.WriteFileAtomicIn(root, agySettingsFileName, output, 0o600); err != nil { + return fmt.Errorf("failed to write agy settings: %w", err) + } + return nil +} diff --git a/cmd/entire/cli/agent/antigravity/title_install_test.go b/cmd/entire/cli/agent/antigravity/title_install_test.go new file mode 100644 index 0000000000..2670f285a3 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/title_install_test.go @@ -0,0 +1,553 @@ +package antigravity + +import ( + "encoding/base64" + "encoding/json" + "github.com/entireio/cli/cmd/entire/cli/agent" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// writeAgySettingsFile writes content to /settings.json. +func writeAgySettingsFile(t *testing.T, dir, content string) { + t.Helper() + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("writeAgySettingsFile: mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(content), 0o600); err != nil { + t.Fatalf("writeAgySettingsFile: write: %v", err) + } +} + +// readTitleCommand parses /settings.json and returns the title.command value, +// or "" if the file is absent or the key is not present. +func readTitleCommand(t *testing.T, dir string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, "settings.json")) + if err != nil { + return "" + } + var s struct { + Title *struct { + Type string `json:"type"` + Command string `json:"command"` + } `json:"title"` + Theme string `json:"theme"` + } + if err := json.Unmarshal(data, &s); err != nil { + t.Fatalf("readTitleCommand: unmarshal: %v", err) + } + if s.Title == nil { + return "" + } + return s.Title.Command +} + +// mustJSON marshals s to a JSON string value (quoted). +func mustJSON(t *testing.T, s string) []byte { + t.Helper() + b, err := json.Marshal(s) + if err != nil { + t.Fatalf("mustJSON: %v", err) + } + return b +} + +func TestInstallTitle_FreshConfig(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("InstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + want := "entire hooks antigravity title-tee" + if got != want { + t.Errorf("title.command = %q, want %q", got, want) + } +} + +func TestInstallTitle_WrapsExistingCommand(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + writeAgySettingsFile(t, cfgDir, `{"theme":"dark","title":{"type":"command","command":"~/bin/my-status.sh"}}`) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("InstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + want := "entire hooks antigravity title-tee --wrap '~/bin/my-status.sh'" + if got != want { + t.Errorf("title.command = %q, want %q", got, want) + } + + // Unknown-key preservation: "theme" must still be present in raw file. + raw, err := os.ReadFile(filepath.Join(cfgDir, "settings.json")) + if err != nil { + t.Fatalf("read settings.json: %v", err) + } + if !strings.Contains(string(raw), `"theme"`) { + t.Error(`settings.json lost "theme" key after install`) + } +} + +// TestInstallTitle_WindowsHostWrapsExistingCommandBase64 pins the Windows +// form: agy runs the title slot through cmd.exe there, so the preserved +// original travels base64url-encoded rather than in POSIX single quotes, and +// uninstall restores it byte for byte. The name carries "Windows" so the +// windows-latest CI job selects it. +func TestInstallTitle_WindowsHostWrapsExistingCommandBase64(t *testing.T) { + // No t.Parallel — uses t.Setenv and the process-global hook-host override. + restore := agent.SetWindowsHookProbeForTesting("windows", nil) + defer restore() + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + // Quotes, an ampersand and a percent: everything cmd.exe would tear apart. + const original = `powershell -c "Write-Host 'x' & echo %CD%"` + writeAgySettingsFile(t, cfgDir, `{"title":{"type":"command","command":`+string(mustJSON(t, original))+`}}`) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("InstallTitleTee: %v", err) + } + got := readTitleCommand(t, cfgDir) + want := "entire hooks antigravity title-tee --wrap-b64 " + base64.RawURLEncoding.EncodeToString([]byte(original)) + if got != want { + t.Errorf("title.command = %q, want %q", got, want) + } + if strings.ContainsAny(strings.TrimPrefix(got, "entire hooks antigravity title-tee --wrap-b64 "), `'"&%^|<>()=`) { + t.Errorf("encoded form carries a cmd.exe metacharacter: %q", got) + } + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + if got := readTitleCommand(t, cfgDir); got != original { + t.Errorf("after uninstall title.command = %q, want the original %q", got, original) + } +} + +func TestExtractWrappedCommand_Forms(t *testing.T) { + t.Parallel() + b64 := func(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) } + cases := []struct { + name string + command string + want string + ok bool + }{ + {"quoted form", "entire hooks antigravity title-tee --wrap '~/bin/x.sh'", "~/bin/x.sh", true}, + {"base64 form", "entire hooks antigravity title-tee --wrap-b64 " + b64(`echo "a" & b`), `echo "a" & b`, true}, + // The quoted payload may itself mention the base64 flag; the quoted + // form wins because it is parsed first. + {"quoted payload naming the b64 flag", "entire hooks antigravity title-tee --wrap 'x --wrap-b64 abc'", "x --wrap-b64 abc", true}, + {"base64 token with trailing junk", "entire hooks antigravity title-tee --wrap-b64 " + b64("x") + " extra", "", false}, + {"base64 token not base64url", "entire hooks antigravity title-tee --wrap-b64 not*base64!", "", false}, + {"empty base64 token", "entire hooks antigravity title-tee --wrap-b64 ", "", false}, + {"bare tee", "entire hooks antigravity title-tee", "", false}, + {"unquoted legacy", "entire hooks antigravity title-tee --wrap unquoted", "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, ok := extractWrappedCommand(tc.command) + if ok != tc.ok || got != tc.want { + t.Errorf("extractWrappedCommand(%q) = (%q, %v), want (%q, %v)", tc.command, got, ok, tc.want, tc.ok) + } + }) + } +} + +func TestInstallTitle_Idempotent(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("first InstallTitleTee: %v", err) + } + first := readTitleCommand(t, cfgDir) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("second InstallTitleTee: %v", err) + } + second := readTitleCommand(t, cfgDir) + + if first != second { + t.Errorf("idempotency: first=%q second=%q", first, second) + } +} + +func TestUninstallTitle_RestoresOriginal(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + writeAgySettingsFile(t, cfgDir, `{"title":{"type":"command","command":"entire hooks antigravity title-tee --wrap '~/bin/my-status.sh'"}}`) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + want := "~/bin/my-status.sh" + if got != want { + t.Errorf("title.command after uninstall = %q, want %q", got, want) + } +} + +func TestUninstallTitle_RemovesBareTee(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + writeAgySettingsFile(t, cfgDir, `{"title":{"type":"command","command":"entire hooks antigravity title-tee"}}`) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + if got != "" { + t.Errorf("title.command after bare-tee uninstall = %q, want empty", got) + } +} + +func TestUninstallTitle_LeavesForeignCommandAlone(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + writeAgySettingsFile(t, cfgDir, `{"title":{"type":"command","command":"~/bin/my-status.sh"}}`) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + want := "~/bin/my-status.sh" + if got != want { + t.Errorf("title.command after foreign-command uninstall = %q, want %q", got, want) + } +} + +func TestUninstallTitle_LeavesUserWrappedTeeAlone(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + // User-authored wrapper that happens to contain the marker string. + const cmd = "my-wrapper.sh 'entire hooks antigravity title-tee'" + writeAgySettingsFile(t, cfgDir, `{"title":{"type":"command","command":"`+cmd+`"}}`) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + if got != cmd { + t.Errorf("title.command = %q, want %q (user wrapper should be left alone)", got, cmd) + } +} + +func TestUninstallTitle_LeavesMalformedWrapAlone(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + // Contains the marker + a --wrap flag but unquoted (malformed) — safer to leave than delete. + const cmd = "entire hooks antigravity title-tee --wrap unquoted" + writeAgySettingsFile(t, cfgDir, `{"title":{"type":"command","command":"`+cmd+`"}}`) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + if got != cmd { + t.Errorf("title.command = %q, want %q (malformed wrap should be left alone)", got, cmd) + } +} + +func TestInstallTitle_WrapsCommandContainingWrapSubstring(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + // Original command itself contains "--wrap fancy" — must survive round-trip. + const original = "~/bin/title.sh --wrap fancy" + origJSON := mustJSON(t, original) + content := `{"title":{"type":"command","command":` + string(origJSON) + `}}` + writeAgySettingsFile(t, cfgDir, content) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("InstallTitleTee: %v", err) + } + + // Installed command should wrap the original. + installed := readTitleCommand(t, cfgDir) + want := "entire hooks antigravity title-tee --wrap '~/bin/title.sh --wrap fancy'" + if installed != want { + t.Errorf("installed title.command = %q, want %q", installed, want) + } + + // Uninstall should restore the original exactly. + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + got := readTitleCommand(t, cfgDir) + if got != original { + t.Errorf("round-trip: got %q, want %q", got, original) + } +} + +func TestUninstallTitle_RemovesBareLocalDevTee(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + // The bare local-dev tee command older versions wrote (local-dev mode was + // removed; see isBareTitleTeeCommand) — must still be removed, from any path. + localDevCmd := "go run '/some/other/worktree/cmd/entire/main.go' hooks antigravity title-tee" + cmdJSON := mustJSON(t, localDevCmd) + content := `{"title":{"type":"command","command":` + string(cmdJSON) + `}}` + writeAgySettingsFile(t, cfgDir, content) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + if got != "" { + t.Errorf("title.command after localDev bare-tee uninstall = %q, want empty", got) + } +} + +func TestInstallUninstall_RoundTripsEmbeddedSingleQuotes(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + original := `echo 'hi there' | awk '{print $1}'` + origJSON := mustJSON(t, original) + content := `{"title":{"type":"command","command":` + string(origJSON) + `}}` + writeAgySettingsFile(t, cfgDir, content) + + if err := InstallTitleTee(); err != nil { + t.Fatalf("InstallTitleTee: %v", err) + } + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + got := readTitleCommand(t, cfgDir) + if got != original { + t.Errorf("round-trip: got %q, want %q", got, original) + } +} + +// TestTitleTeeInstalled covers the three states the doctor check cares about: +// our marker present (true), no title key (false), and a foreign command (false). +func TestTitleTeeInstalled(t *testing.T) { + t.Run("configured", func(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + if err := InstallTitleTee(); err != nil { + t.Fatalf("InstallTitleTee: %v", err) + } + if !TitleTeeInstalled() { + t.Error("TitleTeeInstalled() = false, want true after InstallTitleTee") + } + }) + + t.Run("absent", func(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + // No settings.json at all. + if TitleTeeInstalled() { + t.Error("TitleTeeInstalled() = true, want false with no settings file") + } + // settings.json with no title key. + writeAgySettingsFile(t, cfgDir, `{"theme":"dark"}`) + if TitleTeeInstalled() { + t.Error("TitleTeeInstalled() = true, want false with no title key") + } + }) + + t.Run("foreign command", func(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + writeAgySettingsFile(t, cfgDir, + `{"title":{"type":"command","command":"my-own-title-script.sh"}}`) + if TitleTeeInstalled() { + t.Error("TitleTeeInstalled() = true, want false for a foreign command") + } + }) +} + +// TestUninstallTitleTee_LocalDevFromOtherWorktree pins the cross-worktree +// uninstall: a localDev bare tee installed from worktree A embeds A's absolute +// main.go path, and `entire agent remove antigravity` may run from worktree B +// or outside a repo. The bare-command check must match by SHAPE (go run … +// hooks antigravity title-tee), not by re-resolving the local path at +// uninstall time — otherwise the global title entry is silently orphaned and +// agy spawns a failing `go run` on every state change after A is deleted. +func TestUninstallTitleTee_LocalDevFromOtherWorktree(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + // Run from a non-repo CWD so localDevMainPath() cannot resolve the + // original worktree's path. + t.Chdir(t.TempDir()) + + settings := `{"title":{"type":"command","command":"go run '/deleted/worktree-a/cmd/entire/main.go' hooks antigravity title-tee"}}` + if err := os.WriteFile(filepath.Join(cfgDir, "settings.json"), []byte(settings), 0o600); err != nil { + t.Fatal(err) + } + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + data, err := os.ReadFile(filepath.Join(cfgDir, "settings.json")) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["title"]; ok { + t.Errorf("localDev bare tee from another worktree must be removed on uninstall, got %s", data) + } +} + +// TestUninstallTitleTee_UserWrapperLeftAlone pins the safety property the +// shape check must preserve: a user-authored wrapper that merely CONTAINS the +// tee command is not ours and must not be deleted. +func TestUninstallTitleTee_UserWrapperLeftAlone(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + t.Chdir(t.TempDir()) + + settings := `{"title":{"type":"command","command":"my-wrapper.sh 'entire hooks antigravity title-tee'"}}` + if err := os.WriteFile(filepath.Join(cfgDir, "settings.json"), []byte(settings), 0o600); err != nil { + t.Fatal(err) + } + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee: %v", err) + } + + data, err := os.ReadFile(filepath.Join(cfgDir, "settings.json")) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + if _, ok := raw["title"]; !ok { + t.Error("user-authored wrapper containing the tee marker must be left untouched") + } +} + +// TestInstallTitle_RefusesSymlinkedSettingsFile: settings.json is a name inside +// agy's config directory, and a symlink at it is refused rather than followed +// or replaced. Following it would merge the target's contents into what Entire +// writes; the atomic rename would then silently swap the user's link for a +// regular file. Both happen without a word, so the legible answer is to stop. +func TestInstallTitle_RefusesSymlinkedSettingsFile(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + target := filepath.Join(t.TempDir(), "real-settings.json") + if err := os.WriteFile(target, []byte(`{"theme":"dark"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, filepath.Join(cfgDir, "settings.json")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + if err := InstallTitleTee(); err == nil { + t.Fatal("InstallTitleTee() error = nil, want refusal for a symlinked settings.json") + } + + // The link is intact and its target untouched. + info, err := os.Lstat(filepath.Join(cfgDir, "settings.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatal("the user's symlink was replaced by a regular file") + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"theme":"dark"}` { + t.Fatalf("link target was modified: %s", got) + } + if TitleTeeInstalled() { + t.Fatal("TitleTeeInstalled() = true through a symlink it must not read") + } +} + +// TestUninstallTitle_MissingConfigDirIsNoOp: a machine that never ran agy has +// no config directory at all, and uninstall must read that as nothing to do +// rather than as an error. +func TestUninstallTitle_MissingConfigDirIsNoOp(t *testing.T) { + // No t.Parallel — uses t.Setenv + t.Setenv(configDirEnv, filepath.Join(t.TempDir(), "never-created")) + + if err := UninstallTitleTee(); err != nil { + t.Fatalf("UninstallTitleTee() with no config dir: %v", err) + } + if TitleTeeInstalled() { + t.Fatal("TitleTeeInstalled() = true with no config dir") + } +} + +// TestInstallTitle_SerialisesOnTheSettingsLock: install and uninstall are a +// read-modify-write of agy's machine-global settings.json, so two entire +// processes (an add in one repo racing an add or remove in another) must take +// turns. Pinned by holding the lock externally and watching install wait. +func TestInstallTitle_SerialisesOnTheSettingsLock(t *testing.T) { + // No t.Parallel — uses t.Setenv + cfgDir := t.TempDir() + t.Setenv(configDirEnv, cfgDir) + + release, err := lockAgySettings(true) + if err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { done <- InstallTitleTee() }() + + select { + case <-done: + t.Fatal("InstallTitleTee completed while another process held the settings lock") + case <-time.After(200 * time.Millisecond): + } + release() + select { + case err := <-done: + if err != nil { + t.Fatalf("InstallTitleTee after the lock was released: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("InstallTitleTee did not proceed after the lock was released") + } + if got, want := readTitleCommand(t, cfgDir), "entire hooks antigravity title-tee"; got != want { + t.Fatalf("title.command = %q, want %q", got, want) + } +} diff --git a/cmd/entire/cli/agent/antigravity/transcript.go b/cmd/entire/cli/agent/antigravity/transcript.go new file mode 100644 index 0000000000..54f9fc430e --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/transcript.go @@ -0,0 +1,494 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/osroot" +) + +// Compile-time interface assertions. +var ( + _ agent.PromptExtractor = (*AntigravityAgent)(nil) + _ agent.TranscriptAnalyzer = (*AntigravityAgent)(nil) + _ agent.LateTranscriptWriter = (*AntigravityAgent)(nil) +) + +// Antigravity 2.0 (agy) writes JSONL transcripts at +// ~/.gemini/antigravity-cli/brain//.system_generated/logs/transcript_full.jsonl (the hook payload sends transcript_full; agy also writes a truncated transcript.jsonl alongside it) +// 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) +// } +// Prompt extraction and field-aware modified-file/position analysis +// (TranscriptAnalyzer) are implemented below. ReadTranscript/Chunk/Reassemble +// remain JSONL passthrough, and token counting is handled out-of-band +// elsewhere. See testdata/transcript_sample.jsonl for a captured fixture. + +// agyStep is one line of agy's step-based JSONL transcript. +type agyStep struct { + StepIndex int `json:"step_index"` + Source string `json:"source"` + Type string `json:"type"` + Content string `json:"content"` + ToolCalls []agyStepToolCall `json:"tool_calls"` + // TruncatedFields is set by agy when it trimmed parts of the step while + // persisting it (observed on PLANNER_RESPONSE steps: ~0.8% of + // replace_file_content calls in a daily-driver corpus lose TargetFile while + // every other arg survives). Kept raw: only its presence matters here. + TruncatedFields json.RawMessage `json:"truncated_fields"` +} + +// truncated reports whether agy flagged the step as having truncated fields. +// Absent, null and empty-array all mean "intact". +func (s *agyStep) truncated() bool { + t := bytes.TrimSpace(s.TruncatedFields) + return len(t) > 0 && string(t) != "null" && string(t) != "[]" && string(t) != "{}" +} + +type agyStepToolCall struct { + Name string `json:"name"` + Args map[string]json.RawMessage `json:"args"` +} + +var userRequestRe = regexp.MustCompile(`(?s)\s*(.*?)\s*`) + +// extractUserRequest returns the inner text of the first block, +// or the whole trimmed content if no wrapper is present. +func extractUserRequest(content string) string { + if m := userRequestRe.FindStringSubmatch(content); m != nil { + return strings.TrimSpace(m[1]) + } + // No wrapper: assume the content is itself the prompt. A hypothetical + // metadata-only USER_INPUT step would surface verbatim — acceptable for v1. + return strings.TrimSpace(content) +} + +// forEachNonBlankLine iterates data's non-blank JSONL lines, counting them +// with the codex splitJSONL convention (blank lines skipped BEFORE counting), +// and calls fn for each line past fromOffset. Returns the total non-blank +// line count. +// +// This is the single owner of agy's transcript-offset metric: the position +// one method stores (GetTranscriptPosition → CheckpointTranscriptStart) is +// consumed as fromOffset by the others (ExtractPrompts, +// ExtractModifiedFilesFromOffset), so the counting MUST stay byte-identical +// across all of them — hence one iterator instead of three hand-synced loops. +func forEachNonBlankLine(data []byte, fromOffset int, fn func(raw []byte)) int { + lineNum := 0 + for _, raw := range bytes.Split(data, []byte("\n")) { + if len(bytes.TrimSpace(raw)) == 0 { + continue + } + lineNum++ + if fn != nil && lineNum > fromOffset { + fn(raw) + } + } + return lineNum +} + +// ExtractPrompts implements agent.PromptExtractor. agy's PreInvocation hook +// carries no prompt, so the user prompt is recovered from the transcript's +// USER_INPUT steps. fromOffset is a count of non-blank lines already consumed. +func (a *AntigravityAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { + // Every analyzer reads through ReadTranscript: one contained read when the + // path is inside agy's brain directory, and the package's single ratcheted + // unconfined read otherwise (see agent/transcript_read_guard_test.go). + data, err := a.ReadTranscript(sessionRef) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("antigravity: read transcript for prompts: %w", err) + } + return extractPromptsFromContent(data, fromOffset), nil +} + +// ExtractPromptsFromTranscript implements agent.TranscriptPromptExtractor over +// transcript bytes the caller already holds — condensation's copy of the +// transcript — with the same offset metric as ExtractPrompts. It never reads a +// path, so the prompts it returns always describe the bytes being checkpointed. +func (a *AntigravityAgent) ExtractPromptsFromTranscript(content []byte, fromOffset int) ([]string, error) { //nolint:unparam // the error return is the agent.TranscriptPromptExtractor contract + return extractPromptsFromContent(content, fromOffset), nil +} + +// extractPromptsFromContent is the shared body of both prompt extractors: the +// USER_REQUEST text of every USER_INPUT step after fromOffset non-blank lines. +func extractPromptsFromContent(data []byte, fromOffset int) []string { + var prompts []string + forEachNonBlankLine(data, fromOffset, func(raw []byte) { + var step agyStep + if json.Unmarshal(raw, &step) != nil { + return + } + if step.Type != "USER_INPUT" { + return + } + if text := extractUserRequest(step.Content); text != "" { + prompts = append(prompts, text) + } + }) + return prompts +} + +// CondensedStep is one summarizable unit of an agy transcript. It exists for +// the summarizer (cmd/entire/cli/summarize), which owns the prompt shape but +// not agy's wire format: without it, agy transcripts fell through to the +// Claude JSONL parser, condensed to nothing, and `explain --generate` reported +// "transcript has no content to summarize" for a 2.4 KB transcript. +type CondensedStep struct { + // Role is one of the CondensedRole* constants. + Role string + // Text is the user request (unwrapped from ) or the + // assistant's text; empty for tool steps. + Text string + // ToolName and ToolArgs describe a tool call; ToolArgs values are decoded + // from agy's double-encoded JSON strings where possible. + ToolName string + ToolArgs map[string]any +} + +// Roles of a CondensedStep. +const ( + CondensedRoleUser = "user" + CondensedRoleAssistant = "assistant" + CondensedRoleTool = "tool" +) + +// CondenseTranscript reduces agy step JSONL to user requests, assistant text +// and tool calls, in order. GENERIC steps (tool output) and SYSTEM_MESSAGE +// steps (agy's own injected notices) are skipped; malformed lines are skipped. +func CondenseTranscript(content []byte) []CondensedStep { + var steps []CondensedStep + forEachNonBlankLine(content, 0, func(raw []byte) { + var step agyStep + if json.Unmarshal(raw, &step) != nil { + return + } + switch step.Type { + case "USER_INPUT": + if text := extractUserRequest(step.Content); text != "" { + steps = append(steps, CondensedStep{Role: CondensedRoleUser, Text: text}) + } + case "PLANNER_RESPONSE": + if text := strings.TrimSpace(step.Content); text != "" { + steps = append(steps, CondensedStep{Role: CondensedRoleAssistant, Text: text}) + } + for _, tc := range step.ToolCalls { + if tc.Name == "" { + continue + } + steps = append(steps, CondensedStep{Role: CondensedRoleTool, ToolName: tc.Name, ToolArgs: decodeAgyArgs(tc.Args)}) + } + } + }) + return steps +} + +// decodeAgyArgs decodes a tool call's args, undoing agy's double encoding of +// string values (decodeAgyString) and leaving other JSON values as decoded Go +// values. Best-effort: an undecodable value is dropped. +func decodeAgyArgs(args map[string]json.RawMessage) map[string]any { + if len(args) == 0 { + return nil + } + out := make(map[string]any, len(args)) + for key, raw := range args { + if s, ok := decodeAgyStringOK(raw); ok { + out[key] = s + continue + } + var v any + if json.Unmarshal(raw, &v) == nil && v != nil { + out[key] = v + } + } + return out +} + +// GetTranscriptPosition implements agent.TranscriptAnalyzer. It returns the +// number of non-blank JSONL lines in the transcript, which the framework uses +// as a stable offset to bound subsequent extraction to a single checkpoint +// range. A missing file yields (0, nil) so a not-yet-flushed transcript (agy +// writes asynchronously) doesn't fail the hook. +func (a *AntigravityAgent) GetTranscriptPosition(path string) (int, error) { + if path == "" { + return 0, nil + } + data, err := a.ReadTranscript(path) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return 0, nil + } + return 0, fmt.Errorf("antigravity: transcript position: %w", err) + } + return forEachNonBlankLine(data, 0, nil), nil +} + +// CountTranscriptPosition implements agent.LateTranscriptWriter: agy writes +// its transcript only after the Stop hook, and its offset metric counts +// non-blank lines. Delegating to forEachNonBlankLine keeps this byte-identical +// with GetTranscriptPosition/ExtractPrompts (see the iterator's doc comment). +func (a *AntigravityAgent) CountTranscriptPosition(content []byte) int { + return forEachNonBlankLine(content, 0, nil) +} + +// SliceTranscriptFromPosition implements agent.LateTranscriptWriter. It scopes +// content the same way CountTranscriptPosition counts it, through the one +// iterator that owns the metric. transcript.SliceFromLine cannot stand in: it +// counts raw \n-delimited lines, so a single interior blank line puts the +// summary's window a line off from the offset that was stored for it. +func (a *AntigravityAgent) SliceTranscriptFromPosition(content []byte, startOffset int) []byte { + var kept [][]byte + forEachNonBlankLine(content, startOffset, func(raw []byte) { + kept = append(kept, raw) + }) + if len(kept) == 0 { + return nil + } + return append(bytes.Join(kept, []byte("\n")), '\n') +} + +// ExtractModifiedFilesFromOffset implements agent.TranscriptAnalyzer. It scans +// agy step lines after startOffset for mutating tool calls and returns the +// target file paths they touch, deduplicated, alongside the new line position. +// +// Path convention: returned paths are ABSOLUTE and symlink-resolved — the same +// shape lifecycle.go's parsePreToolUse records into FilesTouched. The framework +// relativizes downstream via FilterAndNormalizePaths -> paths.ToRelativePath +// against the worktree root, so we must NOT pre-relativize here. We mirror +// parsePreToolUse exactly: decode the double-encoded TargetFile arg, then +// resolveAgySymlinks so the path matches what attribution diffs against (e.g. +// macOS /tmp -> /private/tmp). Both helpers live in lifecycle.go (same package) +// and are reused, not duplicated. +// +// The blank-skip -> lineNum++ -> (lineNum <= startOffset) ordering matches +// ExtractPrompts so positions stay consistent across analyzer methods. +func (a *AntigravityAgent) ExtractModifiedFilesFromOffset(ctx context.Context, path string, startOffset int) (files []string, currentPosition int, err error) { + if path == "" { + return nil, 0, nil + } + data, readErr := a.ReadTranscript(path) + if readErr != nil { + if errors.Is(readErr, fs.ErrNotExist) { + return nil, 0, nil + } + return nil, 0, fmt.Errorf("antigravity: extract modified files: %w", readErr) + } + seen := map[string]bool{} + dropped := 0 + lineNum := forEachNonBlankLine(data, startOffset, func(raw []byte) { + var step agyStep + if json.Unmarshal(raw, &step) != nil { + return + } + for _, tc := range step.ToolCalls { + switch tc.Name { + case "write_to_file", "replace_file_content", "multi_replace_file_content": + target := resolveAgySymlinks(decodeAgyString(tc.Args["TargetFile"])) + if target == "" { + // A mutating call with no decodable TargetFile is a file we + // know was modified but cannot name. When agy flagged the + // step as truncated that is the cause (TargetFile was + // trimmed away); say so instead of silently skipping, because + // this analyzer feeds the fallbacks (late-flush, first-turn + // mid-turn commit) where git status may not cover the file. + // The file is not necessarily lost: the live PreToolUse hook + // saw the untruncated call, and a later call on the same + // file names it — this list alone is what is incomplete. + if step.truncated() { + dropped++ + logging.Warn(logging.WithComponent(ctx, "antigravity"), + "transcript step truncated by agy; a modified file cannot be named from this step (it may still be captured by the PreToolUse hook or a later call)", + slog.String("transcript", path), + slog.Int("step_index", step.StepIndex), + slog.String("tool", tc.Name)) + } + continue + } + if !seen[target] { + seen[target] = true + files = append(files, target) + } + } + } + }) + if dropped > 0 { + logging.Warn(logging.WithComponent(ctx, "antigravity"), + "antigravity transcript-derived file list is incomplete (truncated steps); hook-captured files are unaffected", + slog.String("transcript", path), + slog.Int("dropped_truncated_calls", dropped)) + } + return files, lineNum, nil +} + +// ReadTranscript reads a transcript agy's hook payload named. When the path is +// inside agy's brain directory — where every real payload points — the read +// goes through the agent's SessionStore: a name inside a trusted root, no +// symlink followed at any component. A path outside it is the read-side gap +// docs/development/filesystem-safety.md describes (closing it needs RepoPath on +// HookInput), and it stays the one unconfined read the ratchet in +// agent/transcript_read_guard_test.go allows this file; it is never anchored on +// the path's own parent, which would contain nothing while looking like it did. +func (a *AntigravityAgent) ReadTranscript(sessionRef string) ([]byte, error) { + if store, name, err := a.transcriptStore(sessionRef); err == nil { + data, readErr := store.ReadFile(name) + if readErr != nil { + return nil, fmt.Errorf("antigravity: read transcript: %w", readErr) + } + return data, nil + } + data, err := os.ReadFile(sessionRef) //nolint:gosec // the ratcheted unconfined transcript read; see doc comment + 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_full.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 briefly wait for the real transcript first. If it is still missing, +// we materialise an empty placeholder. 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. +// +// The placeholder is a WRITE on a hook-supplied path, so it is created only +// inside agy's brain directory (the agent's SessionStore): parents with +// MkdirAllNoSymlink, the file with O_EXCL through the root, and a path outside +// that directory refused rather than anchored on its own parent +// (docs/development/filesystem-safety.md, "The Root Anchors"). Tests place +// transcripts under ENTIRE_TEST_ANTIGRAVITY_BRAIN_DIR for the same reason. +func (a *AntigravityAgent) PrepareTranscript(ctx context.Context, transcriptRef string) error { + if transcriptRef == "" { + return nil + } + store, name, err := a.transcriptStore(transcriptRef) + if err != nil { + return fmt.Errorf("antigravity: prepare transcript: %w", err) + } + + deadline := time.Now().Add(1 * time.Second) + if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) { + deadline = ctxDeadline + } + + // Poll until the transcript has content, the deadline passes, or ctx ends + // (the select below wakes at once on an already-cancelled ctx). A cancelled + // ctx stops the WAIT, not the fallback: the lifecycle only logs this + // function's error and then requires the file to exist, so returning early + // here would fail the Stop hook — the exact outcome the placeholder exists + // to prevent. Both exits fall through to the exclusive create. +poll: + for { + info, err := store.Lstat(name) + if err == nil { + // Refuse a symlink rather than read through it: the path came from + // agy's hook payload, and a link here would send the condensation + // read wherever it points (docs/development/filesystem-safety.md). + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("antigravity: transcript %s: %w", transcriptRef, osroot.ErrSymlinkedPath) + } + if info.Size() > 0 { + return nil + } + } else if !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("antigravity: stat transcript: %w", err) + } + + if !time.Now().Before(deadline) { + break + } + + wait := 50 * time.Millisecond + if remaining := time.Until(deadline); remaining < wait { + wait = remaining + } + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + break poll + case <-timer.C: + } + } + + // Exclusive create: if agy wrote the real transcript between the last poll + // and here, it wins and the placeholder is skipped — never replaced. + if err := store.CreateExclusive(name, 0o600); err != nil { + if errors.Is(err, fs.ErrExist) { + return nil + } + return fmt.Errorf("antigravity: create empty transcript placeholder: %w", err) + } + return nil +} + +// transcriptStore resolves transcriptRef to agy's brain-directory session store +// (GetSessionDir, which ignores the repo path: agy keeps one brain per user) +// and a name inside it. A path outside the store is reported through +// agent.ErrOutsideSessionStore; it is never re-anchored on the path's own +// parent, the derived base the filesystem-safety rules refuse because it +// contains nothing while looking like it does. +func (a *AntigravityAgent) transcriptStore(transcriptRef string) (*agent.SessionStore, string, error) { + // agy's payload always carries an absolute path. SessionStore.Name would + // accept a relative one as a name inside the store, which is not what a + // relative path means to the callers that pass one (fixtures resolved + // against the working directory), so it is classified as outside instead. + if !filepath.IsAbs(transcriptRef) { + return nil, "", fmt.Errorf("%w: %s is not absolute", agent.ErrOutsideSessionStore, transcriptRef) + } + store, err := agent.OpenSessionStore(a, "") + if err != nil { + return nil, "", err //nolint:wrapcheck // the store already names the agent and directory + } + name, err := store.Name(transcriptRef) + if err != nil { + return nil, "", err //nolint:wrapcheck // preserved for errors.Is(err, agent.ErrOutsideSessionStore) + } + return store, name, 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..862e3ab637 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/transcript_test.go @@ -0,0 +1,572 @@ +package antigravity + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" +) + +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) + } +} + +// brainTranscriptPath points the agent's session store (GetSessionDir) at a +// fresh brain directory and returns a transcript path inside it, in agy's +// layout. PrepareTranscript materialises a placeholder only inside that store +// (a write on a hook-supplied path), so every test that expects one has to +// place the path where agy would. Uses t.Setenv, so callers cannot t.Parallel. +func brainTranscriptPath(t *testing.T) string { + t.Helper() + brain := filepath.Join(t.TempDir(), ".gemini", "antigravity-cli", "brain") + t.Setenv(antigravityTestBrainDirEnv, brain) + return (&AntigravityAgent{}).ResolveSessionFile(brain, "conv") +} + +// 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) { + // Non-existent parent dirs (the brain directory itself included) also + // exercise directory creation through the store. + path := brainTranscriptPath(t) + 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) { + path := brainTranscriptPath(t) + original := []byte(`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE"}` + "\n") + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + 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) + } +} + +func TestPrepareTranscript_WaitsForDelayedTranscript(t *testing.T) { + path := brainTranscriptPath(t) + original := []byte(`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE"}` + "\n") + + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + writeErr := make(chan error, 1) + go func() { + time.Sleep(100 * time.Millisecond) + writeErr <- os.WriteFile(path, original, 0o600) + }() + + a := &AntigravityAgent{} + if err := a.PrepareTranscript(context.Background(), path); err != nil { + t.Fatalf("PrepareTranscript: %v", err) + } + if err := <-writeErr; err != nil { + t.Fatalf("delayed write: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, original) { + t.Fatalf("PrepareTranscript() should wait for delayed transcript, got %q want %q", got, original) + } +} + +// TestPrepareTranscript_RefusesPathOutsideBrainDir: the placeholder is a write +// on a hook-supplied path, so it lands inside agy's brain directory or nowhere. +// Anchoring on the path's own parent instead would contain nothing. +func TestPrepareTranscript_RefusesPathOutsideBrainDir(t *testing.T) { + brainTranscriptPath(t) // pins the store somewhere else + outside := filepath.Join(t.TempDir(), "elsewhere", "transcript_full.jsonl") + + a := &AntigravityAgent{} + err := a.PrepareTranscript(context.Background(), outside) + if !errors.Is(err, agent.ErrOutsideSessionStore) { + t.Fatalf("PrepareTranscript() error = %v, want agent.ErrOutsideSessionStore", err) + } + if _, statErr := os.Lstat(outside); !os.IsNotExist(statErr) { + t.Fatalf("a placeholder must not be created outside the store; Lstat err = %v", statErr) + } +} + +// 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) + } +} + +func TestExtractPrompts_StripsUserRequestWrapper(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + lines := []string{ + `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nread a.txt and exit\n\n\nThe current local time is: x.\n"}`, + `{"step_index":1,"source":"SYSTEM","type":"CONVERSATION_HISTORY","status":"DONE"}`, + `{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","content":"ok"}`, + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + prompts, err := a.ExtractPrompts(path, 0) + if err != nil { + t.Fatalf("ExtractPrompts: %v", err) + } + if len(prompts) != 1 { + t.Fatalf("want 1 prompt, got %d: %#v", len(prompts), prompts) + } + if prompts[0] != "read a.txt and exit" { + t.Errorf("want stripped request, got %q", prompts[0]) + } +} + +func TestExtractPrompts_RespectsOffsetAndMissingFile(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + got, err := a.ExtractPrompts(filepath.Join(t.TempDir(), "nope.jsonl"), 0) + if err != nil || got != nil { + t.Fatalf("missing file: want (nil,nil), got (%#v,%v)", got, err) + } +} + +func TestExtractPrompts_RealFixture(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + prompts, err := a.ExtractPrompts("testdata/transcript_sample.jsonl", 0) + if err != nil { + t.Fatalf("ExtractPrompts: %v", err) + } + if len(prompts) != 1 || prompts[0] != "read a.txt and tell me what it says, then exit" { + t.Fatalf("unexpected prompts: %#v", prompts) + } +} + +func TestExtractPrompts_SkipsLinesAtOrBelowOffset(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "t.jsonl") + lines := []string{ + `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"first"}`, + `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","content":"ok"}`, + `{"step_index":2,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"second"}`, + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + + // offset 0 → both prompts + all, err := a.ExtractPrompts(path, 0) + if err != nil { + t.Fatalf("ExtractPrompts(0): %v", err) + } + if len(all) != 2 || all[0] != "first" || all[1] != "second" { + t.Fatalf("offset 0: want [first second], got %#v", all) + } + + // offset 1 → first non-blank line consumed, so only the second USER_INPUT remains + rest, err := a.ExtractPrompts(path, 1) + if err != nil { + t.Fatalf("ExtractPrompts(1): %v", err) + } + if len(rest) != 1 || rest[0] != "second" { + t.Fatalf("offset 1: want [second], got %#v", rest) + } +} + +func TestGetTranscriptPosition_CountsLines(t *testing.T) { + t.Parallel() + a := &AntigravityAgent{} + pos, err := a.GetTranscriptPosition("testdata/transcript_sample.jsonl") + if err != nil { + t.Fatal(err) + } + if pos <= 0 { + t.Fatalf("want > 0 lines, got %d", pos) + } + if p, e := a.GetTranscriptPosition(filepath.Join(t.TempDir(), "no.jsonl")); p != 0 || e != nil { + t.Fatalf("missing: want (0,nil) got (%d,%v)", p, e) + } +} + +func TestExtractModifiedFiles_FromToolCalls(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "t.jsonl") + lines := []string{ + `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"go"}`, + `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":"\"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/repo/a.txt\"","Overwrite":"true"}}]}`, + `{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"replace_file_content","args":{"TargetFile":"\"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/repo/b.txt\""}}]}`, + `{"step_index":3,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"list_dir","args":{"DirectoryPath":"\"/repo\""}}]}`, + // Re-mutate /repo/a.txt on a later step: must be deduplicated, not double-counted. + `{"step_index":4,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":"\"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/repo/a.txt\"","Overwrite":"true"}}]}`, + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + files, pos, err := a.ExtractModifiedFilesFromOffset(context.Background(), path, 0) + if err != nil { + t.Fatal(err) + } + if pos != 5 { + t.Errorf("want pos 5, got %d", pos) + } + want := map[string]bool{"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/repo/a.txt": true, "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/repo/b.txt": true} + if len(files) != 2 { + t.Fatalf("want 2 modified files (deduped), got %#v", files) + } + for _, f := range files { + if !want[f] { + t.Errorf("unexpected modified file %q", f) + } + } +} + +// TestExtractModifiedFiles_PathConvention pins the path convention: the +// analyzer returns ABSOLUTE, symlink-resolved paths (the same shape +// lifecycle.go's parsePreToolUse records into FilesTouched). The framework +// relativizes downstream via FilterAndNormalizePaths -> paths.ToRelativePath +// against the worktree root, so returning absolute here is correct and must +// NOT be pre-relativized. This test creates a real file under a temp dir and +// asserts the returned path is the absolute, symlink-resolved location. +func TestExtractModifiedFiles_PathConvention(t *testing.T) { + t.Parallel() + dir := t.TempDir() + // Resolve symlinks on the temp dir itself (macOS /tmp -> /private/tmp) so + // our expectation matches what resolveAgySymlinks produces. + resolvedDir, err := filepath.EvalSymlinks(dir) + if err != nil { + t.Fatal(err) + } + target := filepath.Join(resolvedDir, "sub", "real.txt") + if mkErr := os.MkdirAll(filepath.Dir(target), 0o750); mkErr != nil { + t.Fatal(mkErr) + } + if wErr := os.WriteFile(target, []byte("x"), 0o600); wErr != nil { + t.Fatal(wErr) + } + + transcript := filepath.Join(dir, "t.jsonl") + // Note the double-encoded TargetFile arg, mirroring agy's wire format. + line := `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":` + + jsonQuote(t, jsonQuote(t, target)) + `,"Overwrite":"true"}}]}` + if wErr := os.WriteFile(transcript, []byte(line+"\n"), 0o600); wErr != nil { + t.Fatal(wErr) + } + + a := &AntigravityAgent{} + files, _, err := a.ExtractModifiedFilesFromOffset(context.Background(), transcript, 0) + if err != nil { + t.Fatal(err) + } + if len(files) != 1 { + t.Fatalf("want 1 file, got %#v", files) + } + if !filepath.IsAbs(files[0]) { + t.Errorf("expected an absolute path, got %q", files[0]) + } + if files[0] != target { + t.Errorf("want absolute symlink-resolved path %q, got %q", target, files[0]) + } +} + +// jsonQuote returns s wrapped as a JSON string literal (used to build the +// double-encoded TargetFile arg in the path-convention test). +func jsonQuote(t *testing.T, s string) string { + t.Helper() + b, err := json.Marshal(s) + if err != nil { + t.Fatalf("jsonQuote(%q): %v", s, err) + } + return string(b) +} + +// TestExtractModifiedFiles_TruncatedStepDoesNotPanicAndKeepsOthers pins the +// degrade path for agy's `truncated_fields`: a mutating tool call whose +// TargetFile was trimmed away (observed live, ~0.8% of replace_file_content +// calls in a daily-driver corpus) must not abort extraction or poison the +// other files in the same range. The dropped call is reported via a WARN log +// (not asserted here; the logging package has no capture hook) — the +// contract this test locks in is "no error, other files intact, position +// still advances". +func TestExtractModifiedFiles_TruncatedStepDoesNotPanicAndKeepsOthers(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "t.jsonl") + lines := []string{ + `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"go"}`, + // TargetFile trimmed by agy; every other arg survived. + `{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","truncated_fields":["tool_calls[0].args.TargetFile"],"tool_calls":[{"name":"replace_file_content","args":{"ReplacementChunks":"\"[]\"","TargetContent":"\"x\""}}]}`, + `{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":"\"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/repo/b.txt\""}}]}`, + } + if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { + t.Fatal(err) + } + a := &AntigravityAgent{} + files, pos, err := a.ExtractModifiedFilesFromOffset(context.Background(), path, 0) + if err != nil { + t.Fatalf("truncated step must degrade, not error: %v", err) + } + if pos != 3 { + t.Errorf("want pos 3, got %d", pos) + } + if len(files) != 1 || files[0] != "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/repo/b.txt" { + t.Fatalf("want only the intact file, got %#v", files) + } +} + +func TestAgyStepTruncated(t *testing.T) { + t.Parallel() + cases := map[string]bool{ + ``: false, + `null`: false, + `[]`: false, + `["tool_calls[0].args.TargetFile"]`: true, + `{"tool_calls":["TargetFile"]}`: true, + } + for raw, want := range cases { + step := agyStep{TruncatedFields: json.RawMessage(raw)} + if got := step.truncated(); got != want { + t.Errorf("truncated_fields=%s: truncated() = %v, want %v", raw, got, want) + } + } +} + +// A cancelled context must still leave the placeholder behind: the lifecycle +// only logs PrepareTranscript's error and then requires the file to exist, so +// an early return here would fail the Stop hook the placeholder exists to save. +func TestPrepareTranscript_CancelledContextStillCreatesPlaceholder(t *testing.T) { + path := brainTranscriptPath(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + a := &AntigravityAgent{} + if err := a.PrepareTranscript(ctx, path); err != nil { + t.Fatalf("PrepareTranscript with cancelled ctx: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("placeholder missing after cancelled ctx: %v", err) + } + if info.Size() != 0 { + t.Fatalf("placeholder size = %d, want 0", info.Size()) + } +} + +// TestPrepareTranscript_RefusesSymlinkedTranscript: a symlink at the transcript +// path is refused rather than followed (filesystem-safety.md). A Stat would have +// reported the target's size and, for a dangling link, created a file at the far +// end of it; the store's Lstat reports the link itself and PrepareTranscript +// stops there, leaving the link's target untouched. +func TestPrepareTranscript_RefusesSymlinkedTranscript(t *testing.T) { + path := brainTranscriptPath(t) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "victim.jsonl") + if err := os.Symlink(target, path); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + a := &AntigravityAgent{} + if err := a.PrepareTranscript(context.Background(), path); err == nil { + t.Fatal("PrepareTranscript() error = nil, want refusal for a symlinked transcript") + } + if _, statErr := os.Lstat(target); !os.IsNotExist(statErr) { + t.Fatalf("nothing may be created at the link's target; Lstat err = %v", statErr) + } +} + +// TestReadTranscript_InsideBrainDirRefusesSymlink: a transcript inside agy's +// brain directory is read through the session store, so a symlink there is +// refused instead of followed to wherever it points. Outside the store the +// read is the ratcheted unconfined one (agent/transcript_read_guard_test.go). +func TestReadTranscript_InsideBrainDirRefusesSymlink(t *testing.T) { + path := brainTranscriptPath(t) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + target := filepath.Join(t.TempDir(), "secret.jsonl") + if err := os.WriteFile(target, []byte(`{"step_index":0,"type":"USER_INPUT","content":"leak"}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Skipf("symlink not supported: %v", err) + } + + a := &AntigravityAgent{} + if _, err := a.ReadTranscript(path); err == nil { + t.Fatal("ReadTranscript() error = nil, want refusal for a symlinked transcript inside the store") + } + prompts, err := a.ExtractPrompts(path, 0) + if err == nil || len(prompts) != 0 { + t.Fatalf("ExtractPrompts() = %v, %v; want refusal and no prompts", prompts, err) + } + if _, posErr := a.GetTranscriptPosition(path); posErr == nil { + t.Fatal("GetTranscriptPosition() error = nil, want refusal") + } +} + +// The bytes extractor is what condensation uses; it must agree with the +// path-based one line for line, including the offset metric (non-blank lines). +func TestExtractPromptsFromTranscript_MatchesPathBasedExtractor(t *testing.T) { + t.Parallel() + content := []byte(`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nfirst ask\n"} + +{"step_index":1,"type":"PLANNER_RESPONSE","status":"DONE"} +{"step_index":2,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nadd another\n"} +`) + a := &AntigravityAgent{} + all, err := a.ExtractPromptsFromTranscript(content, 0) + if err != nil || len(all) != 2 || all[0] != "first ask" || all[1] != "add another" { + t.Fatalf("offset 0: got %v, %v", all, err) + } + later, err := a.ExtractPromptsFromTranscript(content, 2) + if err != nil || len(later) != 1 || later[0] != "add another" { + t.Fatalf("offset 2 (after two non-blank lines): got %v, %v", later, err) + } + + path := filepath.Join(t.TempDir(), "t.jsonl") + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + fromPath, err := a.ExtractPrompts(path, 2) + if err != nil || len(fromPath) != 1 || fromPath[0] != later[0] { + t.Fatalf("path-based extractor disagrees: %v, %v", fromPath, err) + } +} + +// CondenseTranscript is what the summarizer sees. Shapes are the ones agy 1.2.7 +// really writes: a wrapped USER_REQUEST, a PLANNER_RESPONSE carrying only +// tool_calls with double-encoded args, a GENERIC tool-output step (skipped), +// and a PLANNER_RESPONSE with the assistant's text. +func TestCondenseTranscript_RealStepShapes(t *testing.T) { + t.Parallel() + content := []byte(`{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nCreate red.md\n"} +{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":"\"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/ws/red.md\"","Overwrite":"false"}}]} +{"step_index":2,"source":"MODEL","type":"GENERIC","status":"DONE","content":"Created At: ..."} +{"step_index":3,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","content":"Created [red.md](file:///ws/red.md)."} +{"step_index":4,"source":"SYSTEM","type":"SYSTEM_MESSAGE","status":"DONE","content":"not from the user"} +not json +`) + steps := CondenseTranscript(content) + if len(steps) != 3 { + t.Fatalf("got %d steps, want 3: %+v", len(steps), steps) + } + if steps[0].Role != CondensedRoleUser || steps[0].Text != "Create red.md" { + t.Errorf("step 0 = %+v, want the unwrapped user request", steps[0]) + } + if steps[1].Role != CondensedRoleTool || steps[1].ToolName != "write_to_file" || steps[1].ToolArgs["TargetFile"] != "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/ws/red.md" { + t.Errorf("step 1 = %+v, want the tool call with its TargetFile decoded", steps[1]) + } + if steps[2].Role != CondensedRoleAssistant || steps[2].Text != "Created [red.md](file:///ws/red.md)." { + t.Errorf("step 2 = %+v, want the assistant text", steps[2]) + } +} + +// agy double-encodes string args, so an empty string arrives as the raw value +// `"\"\""`. It must survive as "", not as the two-character literal `""` that +// a plain decode of the raw value produces — tool args go into generated +// summaries, where a corrupted value reads as real content. +func TestDecodeAgyArgs_PreservesEmptyStrings(t *testing.T) { + t.Parallel() + + got := decodeAgyArgs(map[string]json.RawMessage{ + "doubleEmpty": json.RawMessage(`"\"\""`), + "plainEmpty": json.RawMessage(`""`), + "doubleValue": json.RawMessage(`"\"hi\""`), + "number": json.RawMessage(`42`), + }) + + for key, want := range map[string]any{ + "doubleEmpty": "", + "plainEmpty": "", + "doubleValue": "hi", + "number": float64(42), + } { + if got[key] != want { + t.Errorf("decodeAgyArgs()[%q] = %#v, want %#v", key, got[key], want) + } + } +} + +// The offset stored for a checkpoint counts non-blank lines, so the slice that +// scopes a summary to that checkpoint has to count them the same way. A raw +// \n-line slicer drifts by one for each interior blank line, which silently +// puts the summary's window over the wrong turns. +func TestSliceTranscriptFromPosition_IgnoresInteriorBlankLines(t *testing.T) { + t.Parallel() + + const tail = `{"n":3}` + "\n" + `{"n":4}` + "\n" + dense := `{"n":1}` + "\n" + `{"n":2}` + "\n" + tail + sparse := `{"n":1}` + "\n\n" + `{"n":2}` + "\n \n" + tail + + a := &AntigravityAgent{} + if got := a.CountTranscriptPosition([]byte(sparse)); got != 4 { + t.Fatalf("CountTranscriptPosition(sparse) = %d, want 4", got) + } + + for name, content := range map[string]string{"dense": dense, "sparse": sparse} { + if got := string(a.SliceTranscriptFromPosition([]byte(content), 2)); got != tail { + t.Errorf("SliceTranscriptFromPosition(%s, 2) = %q, want %q", name, got, tail) + } + } + + if got := a.SliceTranscriptFromPosition([]byte(dense), 4); got != nil { + t.Errorf("nothing past the end should slice to nil, got %q", got) + } +} diff --git a/cmd/entire/cli/agent/antigravity/types.go b/cmd/entire/cli/agent/antigravity/types.go new file mode 100644 index 0000000000..955266f472 --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/types.go @@ -0,0 +1,89 @@ +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. It mirrors +// agy's hooks.json schema (https://antigravity.google/docs — Hooks), including +// event keys we don't install (PostToolUse/PostInvocation) so round-tripping a +// user's file never drops data and the install idempotency comparison detects +// stale Entire entries that still carry them. +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"` +} + +// hookTypeCommand is the only handler type agy defines for hooks.json and +// for the global title slot: a shell command. +const hookTypeCommand = "command" + +// 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"` +} + +// Payload structs below decode only the fields the integration consumes. +// agy sends more (workspacePaths, artifactDirectoryPath, stepIdx, +// initialNumSteps, executionNum, terminationReason, error) — see the agy hooks +// docs for the full schema; add fields here only when something reads them. + +// CommonPayload contains the system metadata fields the integration consumes +// from every hook payload. +type CommonPayload struct { + ConversationID string `json:"conversationId"` + TranscriptPath string `json:"transcriptPath"` +} + +// 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"` +} + +// InvocationPayload is the stdin payload for the PreInvocation hook. +// +// invocationNum is 0-indexed (the first model invocation of a conversation is +// 0). initialNumSteps is deliberately not decoded: agy inserts the user prompt +// as a step before the first model call, so it is already 1 on the first +// invocation and unusable as a "first?" signal. +type InvocationPayload struct { + CommonPayload + + InvocationNum int `json:"invocationNum"` +} + +// StopPayload is the stdin payload for the Stop hook. +type StopPayload struct { + CommonPayload + + FullyIdle bool `json:"fullyIdle"` // Required +} 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..24d3e6649c --- /dev/null +++ b/cmd/entire/cli/agent/antigravity/types_test.go @@ -0,0 +1,83 @@ +package antigravity + +import ( + "encoding/json" + "os" + "testing" +) + +const ( + testConversationID = "ec33ebf9-0cba-4100-8142-c61503f6c587" + testTranscriptPath = "/workspace/project/.gemini/jetski/transcript.jsonl" + testWorkspacePath = "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/workspace/project" +) + +// The payload structs decode only the fields the integration consumes; the +// fixtures carry agy's full documented payloads, so these tests also pin that +// unknown fields (workspacePaths, stepIdx, initialNumSteps, executionNum, +// terminationReason, error, artifactDirectoryPath) are tolerated. + +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.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). See parsePreInvocation comment block. + if p.InvocationNum != 1 { + t.Errorf("InvocationNum = %d", p.InvocationNum) + } + if p.TranscriptPath != testTranscriptPath { + t.Errorf("TranscriptPath = %q", p.TranscriptPath) + } +} + +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.FullyIdle { + t.Error("FullyIdle should be true") + } + if p.TranscriptPath != testTranscriptPath { + t.Errorf("TranscriptPath = %q", p.TranscriptPath) + } +} diff --git a/cmd/entire/cli/agent/architecture_test.go b/cmd/entire/cli/agent/architecture_test.go index c91d717c9c..4e852d9d98 100644 --- a/cmd/entire/cli/agent/architecture_test.go +++ b/cmd/entire/cli/agent/architecture_test.go @@ -6,6 +6,7 @@ import ( "go/token" "os" "path/filepath" + "slices" "strconv" "strings" "testing" @@ -61,6 +62,20 @@ func TestAgentPackages_NoForbiddenImports(t *testing.T) { repoPrefix + "testutil", // canonical isolated repository fixtures for agent tests } + // Imports allowed in ONE agent package, keyed by its directory name. + // Unlike allowedPrefixes, an entry here does not widen the contract for + // every agent: it is one agent's answer to one agent's problem, and a + // second agent that wants it has to say so here first. + scopedPrefixes := map[string][]string{ + "antigravity": { + // Cross-process advisory locks. agy fires its title command on + // every state change without serializing, so the title-tee takes + // a per-conversation flock around its dedup-and-append. No other + // agent's transport has that shape. + repoPrefix + "internal/flock", + }, + } + agentDir := findAgentDir(t) agentPkgs := discoverAgentPackages(t, agentDir) @@ -72,6 +87,13 @@ func TestAgentPackages_NoForbiddenImports(t *testing.T) { pkgName := filepath.Base(pkgDir) t.Run(pkgName, func(t *testing.T) { t.Parallel() + // Clone, don't append onto allowedPrefixes: appending would write + // this package's scoped entries into that slice's backing array + // whenever it has spare capacity, handing every package checked + // afterwards the exception scopedPrefixes exists to contain. It + // holds today only because allowedPrefixes is a literal whose cap + // equals its len; that is not a property to depend on. + allowedHere := append(slices.Clone(allowedPrefixes), scopedPrefixes[pkgName]...) imports := extractImports(t, pkgDir) for _, imp := range imports { if !strings.HasPrefix(imp, repoPrefix) && imp != forbiddenCLIPackage { @@ -97,16 +119,17 @@ func TestAgentPackages_NoForbiddenImports(t *testing.T) { continue } - // Check it's in the allowed list + // Check it's in the allowed list, or scoped to this package allowed := false - for _, prefix := range allowedPrefixes { + for _, prefix := range allowedHere { if imp == prefix || strings.HasPrefix(imp, prefix+"/") { allowed = true break } } if !allowed { - t.Errorf("unexpected internal import %q — if this is intentional, add it to allowedPrefixes in architecture_test.go", imp) + t.Errorf("unexpected internal import %q — if every agent may use it, add it to allowedPrefixes in architecture_test.go; "+ + "if it answers this agent's problem alone, add it to scopedPrefixes[%q] with the reason", imp, pkgName) } } }) diff --git a/cmd/entire/cli/agent/capabilities.go b/cmd/entire/cli/agent/capabilities.go index cd8416f440..a63c6cea58 100644 --- a/cmd/entire/cli/agent/capabilities.go +++ b/cmd/entire/cli/agent/capabilities.go @@ -149,6 +149,42 @@ func AsTokenCalculator(ag Agent) (TokenCalculator, bool) { return declaredCapability[TokenCalculator](ag, func(c DeclaredCaps) bool { return c.TokenCalculator }) } +// AsLateTranscriptWriter returns the agent as LateTranscriptWriter if supported. +// External (CapabilityDeclarer) agents are excluded: the late-transcript trait +// is wire-format knowledge the external protocol does not currently express, +// and DeclaredCaps has no field for this capability to opt into. +func AsLateTranscriptWriter(ag Agent) (LateTranscriptWriter, bool) { + if ag == nil { + return nil, false + } + lw, ok := ag.(LateTranscriptWriter) + if !ok { + return nil, false + } + if _, isDeclarer := ag.(CapabilityDeclarer); isDeclarer { + return nil, false + } + return lw, true +} + +// AsOutOfBandTokenSource returns the agent as OutOfBandTokenSource if supported. +// External (CapabilityDeclarer) agents are excluded because the out-of-band +// store is fed by a built-in shim subcommand they cannot provide, and +// DeclaredCaps has no field for this capability to opt into. +func AsOutOfBandTokenSource(ag Agent) (OutOfBandTokenSource, bool) { + if ag == nil { + return nil, false + } + src, ok := ag.(OutOfBandTokenSource) + if !ok { + return nil, false + } + if _, isDeclarer := ag.(CapabilityDeclarer); isDeclarer { + return nil, false + } + return src, true +} + // AsInventoryAwareExtractor returns the agent as InventoryAwareExtractor when // it implements the built-in-only inventory protocol. External agents cannot // declare this capability because its authoritative child ledger is internal to @@ -200,6 +236,13 @@ func AsPromptExtractor(ag Agent) (PromptExtractor, bool) { return declaredCapability[PromptExtractor](ag, func(c DeclaredCaps) bool { return c.TranscriptAnalyzer }) } +// AsTranscriptPromptExtractor returns the agent as TranscriptPromptExtractor +// under the same capability gate as AsPromptExtractor: it is transcript +// analysis over bytes instead of a path. +func AsTranscriptPromptExtractor(ag Agent) (TranscriptPromptExtractor, bool) { + return declaredCapability[TranscriptPromptExtractor](ag, func(c DeclaredCaps) bool { return c.TranscriptAnalyzer }) +} + // AsSubagentAwareExtractor returns the agent as SubagentAwareExtractor if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. func AsSubagentAwareExtractor(ag Agent) (SubagentAwareExtractor, bool) { diff --git a/cmd/entire/cli/agent/capabilities_test.go b/cmd/entire/cli/agent/capabilities_test.go index 0dd3da1c70..20e891e1e2 100644 --- a/cmd/entire/cli/agent/capabilities_test.go +++ b/cmd/entire/cli/agent/capabilities_test.go @@ -2,6 +2,7 @@ package agent import ( "context" + "encoding/json" "io" "testing" @@ -65,7 +66,7 @@ func (m *mockFullAgent) AreHooksInstalled(context.Context) (bool, error) { retur // TranscriptAnalyzer func (m *mockFullAgent) GetTranscriptPosition(string) (int, error) { return 0, nil } -func (m *mockFullAgent) ExtractModifiedFilesFromOffset(string, int) ([]string, int, error) { +func (m *mockFullAgent) ExtractModifiedFilesFromOffset(context.Context, string, int) ([]string, int, error) { return nil, 0, nil } func (m *mockFullAgent) ExtractPrompts(string, int) ([]string, error) { return nil, nil } @@ -90,6 +91,15 @@ func (m *mockFullAgent) GenerateText(context.Context, string, string) (string, e return "", nil } +// OutOfBandTokenSource (mockFullAgent is a CapabilityDeclarer, so AsOutOfBandTokenSource +// must still exclude it — verifies the built-in-only gate). +func (m *mockFullAgent) SnapshotTokenBaseline(context.Context, string) (json.RawMessage, error) { + return nil, nil +} +func (m *mockFullAgent) CalculateTokenUsageSince(context.Context, string, json.RawMessage) (*TokenUsage, error) { + return nil, nil //nolint:nilnil // test mock +} + // StreamingTextGenerator func (m *mockFullAgent) GenerateTextStreaming(context.Context, string, string, ProgressFn) (string, error) { return "", nil @@ -129,6 +139,19 @@ func (m *mockBuiltinPromptAgent) ExtractPrompts(string, int) ([]string, error) { return []string{"test prompt"}, nil } +// mockBuiltinOOBAgent is a built-in agent that implements OutOfBandTokenSource +// but NOT CapabilityDeclarer. +type mockBuiltinOOBAgent struct { + mockBaseAgent +} + +func (m *mockBuiltinOOBAgent) SnapshotTokenBaseline(context.Context, string) (json.RawMessage, error) { + return nil, nil +} +func (m *mockBuiltinOOBAgent) CalculateTokenUsageSince(context.Context, string, json.RawMessage) (*TokenUsage, error) { + return nil, nil //nolint:nilnil // test mock +} + // --- Tests --- func TestAsHookSupport(t *testing.T) { @@ -431,6 +454,44 @@ func TestAsSubagentAwareExtractor(t *testing.T) { }) } +func TestAsOutOfBandTokenSource(t *testing.T) { + t.Parallel() + + t.Run("nil agent", func(t *testing.T) { + t.Parallel() + _, ok := AsOutOfBandTokenSource(nil) + if ok { + t.Error("expected false for nil agent") + } + }) + + t.Run("not implemented", func(t *testing.T) { + t.Parallel() + _, ok := AsOutOfBandTokenSource(&mockBaseAgent{}) + if ok { + t.Error("expected false for agent not implementing OutOfBandTokenSource") + } + }) + + t.Run("builtin agent", func(t *testing.T) { + t.Parallel() + src, ok := AsOutOfBandTokenSource(&mockBuiltinOOBAgent{}) + if !ok || src == nil { + t.Error("expected true for built-in agent implementing OutOfBandTokenSource") + } + }) + + t.Run("capability declarer excluded", func(t *testing.T) { + t.Parallel() + // mockFullAgent implements the interface but is a CapabilityDeclarer + // (external agent), so it must be excluded. + _, ok := AsOutOfBandTokenSource(&mockFullAgent{}) + if ok { + t.Error("expected false for CapabilityDeclarer agent") + } + }) +} + func TestAsPromptExtractor(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/agent/claudecode/claude.go b/cmd/entire/cli/agent/claudecode/claude.go index 8a373f6eae..a9531bc675 100644 --- a/cmd/entire/cli/agent/claudecode/claude.go +++ b/cmd/entire/cli/agent/claudecode/claude.go @@ -236,7 +236,7 @@ func (c *ClaudeCodeAgent) GetTranscriptPosition(path string) (int, error) { // - files: list of file paths modified by Claude (from Write/Edit tools) // - currentPosition: total number of lines in the file // - error: any error encountered during reading -func (c *ClaudeCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { +func (c *ClaudeCodeAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) (files []string, currentPosition int, err error) { if path == "" { return nil, 0, nil } diff --git a/cmd/entire/cli/agent/codex/transcript.go b/cmd/entire/cli/agent/codex/transcript.go index c51afc211d..fe5c9288f3 100644 --- a/cmd/entire/cli/agent/codex/transcript.go +++ b/cmd/entire/cli/agent/codex/transcript.go @@ -286,7 +286,7 @@ func (c *CodexAgent) GetTranscriptPosition(path string) (int, error) { } // ExtractModifiedFilesFromOffset extracts files modified since a given line offset. -func (c *CodexAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { +func (c *CodexAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) (files []string, currentPosition int, err error) { if path == "" { return nil, 0, nil } diff --git a/cmd/entire/cli/agent/codex/transcript_test.go b/cmd/entire/cli/agent/codex/transcript_test.go index cd70d8345c..7fac1d3422 100644 --- a/cmd/entire/cli/agent/codex/transcript_test.go +++ b/cmd/entire/cli/agent/codex/transcript_test.go @@ -2,6 +2,7 @@ package codex import ( "bytes" + "context" "encoding/json" "os" "path/filepath" @@ -134,7 +135,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { path := writeSampleRollout(t) // From beginning — should find all files - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 0) require.NoError(t, err) require.Equal(t, 12, pos) require.ElementsMatch(t, []string{"hello.txt", "docs/readme.md"}, files) @@ -146,7 +147,7 @@ func TestExtractModifiedFilesFromOffset_WithOffset(t *testing.T) { path := writeSampleRollout(t) // Skip first 7 lines (past the first apply_patch) — should only find second patch files - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 7) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 7) require.NoError(t, err) require.Equal(t, 12, pos) require.ElementsMatch(t, []string{"docs/readme.md", "hello.txt"}, files) @@ -157,7 +158,7 @@ func TestExtractModifiedFilesFromOffset_PastEnd(t *testing.T) { ag := &CodexAgent{} path := writeSampleRollout(t) - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 100) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 100) require.NoError(t, err) require.Equal(t, 12, pos) require.Empty(t, files) diff --git a/cmd/entire/cli/agent/copilotcli/transcript.go b/cmd/entire/cli/agent/copilotcli/transcript.go index b5c19d8f31..0659a2ec8d 100644 --- a/cmd/entire/cli/agent/copilotcli/transcript.go +++ b/cmd/entire/cli/agent/copilotcli/transcript.go @@ -486,7 +486,7 @@ func (c *CopilotCLIAgent) GetTranscriptPosition(path string) (int, error) { // - files: list of file paths modified by Copilot (from tool.execution_complete events) // - currentPosition: total number of lines in the file // - error: any error encountered during reading -func (c *CopilotCLIAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) (files []string, currentPosition int, err error) { +func (c *CopilotCLIAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) (files []string, currentPosition int, err error) { if path == "" { return nil, 0, nil } diff --git a/cmd/entire/cli/agent/copilotcli/transcript_test.go b/cmd/entire/cli/agent/copilotcli/transcript_test.go index 41bc53e4db..ad43f0c400 100644 --- a/cmd/entire/cli/agent/copilotcli/transcript_test.go +++ b/cmd/entire/cli/agent/copilotcli/transcript_test.go @@ -265,7 +265,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { ag := &CopilotCLIAgent{} path := writeTestJSONL(t, testJSONLLines) - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -287,7 +287,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { // Offset 5 means skip first 5 lines (tool.execution_complete is line 5) // so only lines 6 and 7 remain (assistant.message and assistant.turn_end) - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 5) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 5) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -303,7 +303,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { t.Parallel() ag := &CopilotCLIAgent{} - files, pos, err := ag.ExtractModifiedFilesFromOffset("", 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), "", 0) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/cmd/entire/cli/agent/cursor/transcript.go b/cmd/entire/cli/agent/cursor/transcript.go index b53e758d95..9ac616b960 100644 --- a/cmd/entire/cli/agent/cursor/transcript.go +++ b/cmd/entire/cli/agent/cursor/transcript.go @@ -2,6 +2,7 @@ package cursor import ( "bufio" + "context" "encoding/json" "errors" "fmt" @@ -154,7 +155,7 @@ func ExtractModifiedFiles(lines []transcript.Line) []string { // A missing transcript is not an error: Cursor reports transcript_path as null in // CLI mode, so ResolveSessionFile predicts a path that may not exist yet, and this // runs on capture paths that must fail open (matching GetTranscriptPosition). -func (c *CursorAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { +func (c *CursorAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) ([]string, int, error) { if path == "" { return nil, 0, nil } diff --git a/cmd/entire/cli/agent/cursor/transcript_test.go b/cmd/entire/cli/agent/cursor/transcript_test.go index ee7532c327..f34197f336 100644 --- a/cmd/entire/cli/agent/cursor/transcript_test.go +++ b/cmd/entire/cli/agent/cursor/transcript_test.go @@ -1,6 +1,7 @@ package cursor import ( + "context" "encoding/json" "os" "path/filepath" @@ -174,7 +175,7 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset(t *testing.T) { // The sample is a text-only conversation, so there is nothing to attribute -- // but the position must still advance to the sample's line count. Returning a // constant 0 here is what the removed stub did. - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset() error = %v, want nil", err) } @@ -193,7 +194,7 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset_NonexistentFile(t *testing.T t.Parallel() ag := &CursorAgent{} - files, pos, err := ag.ExtractModifiedFilesFromOffset("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/nonexistent/path.jsonl", 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), "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/nonexistent/path.jsonl", 0) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset() error = %v, want nil", err) } @@ -209,7 +210,7 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset_EmptyPath(t *testing.T) { t.Parallel() ag := &CursorAgent{} - files, pos, err := ag.ExtractModifiedFilesFromOffset("", 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), "", 0) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset() error = %v", err) } @@ -349,7 +350,7 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset_RealSession(t *testing.T) { t.Parallel() ag := &CursorAgent{} - files, pos, err := ag.ExtractModifiedFilesFromOffset(realSessionFixture, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), realSessionFixture, 0) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset() error = %v", err) } @@ -381,7 +382,7 @@ func TestCursorAgent_ExtractModifiedFilesFromOffset_RealSessionOffsets(t *testin for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - files, pos, err := ag.ExtractModifiedFilesFromOffset(realSessionFixture, tt.offset) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), realSessionFixture, tt.offset) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset() error = %v", err) } diff --git a/cmd/entire/cli/agent/event.go b/cmd/entire/cli/agent/event.go index 05fa6465e2..326f8adddc 100644 --- a/cmd/entire/cli/agent/event.go +++ b/cmd/entire/cli/agent/event.go @@ -202,6 +202,15 @@ type Event struct { // Metadata holds agent-specific state that the framework stores and makes available // on subsequent events. Examples: Pi's activeLeafId, Cursor's is_background_agent. Metadata map[string]string + + // SuppressIfSessionActive marks a TurnStart the dispatcher should drop when + // an active (mid-turn) session already exists for SessionID. It exists for + // agents whose per-invocation hooks can't distinguish a follow-up model call + // from the first call of a resumed turn (e.g. Antigravity's PreInvocation, + // which fires per model invocation): the parser emits a conditional TurnStart + // and the dispatcher resolves it against session state (which agent packages + // may not read directly). + SuppressIfSessionActive bool } // ReadAndParseHookInput decodes a single JSON hook payload from stdin into the diff --git a/cmd/entire/cli/agent/external/capabilities.go b/cmd/entire/cli/agent/external/capabilities.go index 64ef698cf0..75d17fb963 100644 --- a/cmd/entire/cli/agent/external/capabilities.go +++ b/cmd/entire/cli/agent/external/capabilities.go @@ -95,8 +95,8 @@ func (w *wrappedAgent) AreHooksInstalled(ctx context.Context) (bool, error) { func (w *wrappedAgent) GetTranscriptPosition(path string) (int, error) { return w.ea.GetTranscriptPosition(path) } -func (w *wrappedAgent) ExtractModifiedFilesFromOffset(path string, offset int) ([]string, int, error) { - return w.ea.ExtractModifiedFilesFromOffset(path, offset) +func (w *wrappedAgent) ExtractModifiedFilesFromOffset(ctx context.Context, path string, offset int) ([]string, int, error) { + return w.ea.ExtractModifiedFilesFromOffset(ctx, path, offset) } func (w *wrappedAgent) ExtractPrompts(ref string, offset int) ([]string, error) { return w.ea.ExtractPrompts(ref, offset) diff --git a/cmd/entire/cli/agent/external/external.go b/cmd/entire/cli/agent/external/external.go index f762521f7d..80673c1ca2 100644 --- a/cmd/entire/cli/agent/external/external.go +++ b/cmd/entire/cli/agent/external/external.go @@ -341,7 +341,7 @@ func (e *Agent) GetTranscriptPosition(path string) (int, error) { return resp.Position, nil } -func (e *Agent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { +func (e *Agent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) ([]string, int, error) { stdout, err := e.run(context.Background(), nil, "extract-modified-files", "--path", path, "--offset", strconv.Itoa(startOffset)) if err != nil { diff --git a/cmd/entire/cli/agent/external/external_test.go b/cmd/entire/cli/agent/external/external_test.go index 2400d7e141..4c6f877483 100644 --- a/cmd/entire/cli/agent/external/external_test.go +++ b/cmd/entire/cli/agent/external/external_test.go @@ -599,7 +599,7 @@ func TestExternalAgent_TranscriptAnalyzer(t *testing.T) { t.Errorf("GetTranscriptPosition() = %d, want 42", pos) } - files, curPos, err := ea.ExtractModifiedFilesFromOffset("/path", 0) + files, curPos, err := ea.ExtractModifiedFilesFromOffset(context.Background(), "/path", 0) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset: %v", err) } diff --git a/cmd/entire/cli/agent/factoryaidroid/lifecycle.go b/cmd/entire/cli/agent/factoryaidroid/lifecycle.go index eb8d23c5fe..298b18f056 100644 --- a/cmd/entire/cli/agent/factoryaidroid/lifecycle.go +++ b/cmd/entire/cli/agent/factoryaidroid/lifecycle.go @@ -88,7 +88,7 @@ func (f *FactoryAIDroidAgent) GetTranscriptPosition(path string) (int, error) { } // ExtractModifiedFilesFromOffset extracts files modified since a given line offset. -func (f *FactoryAIDroidAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { +func (f *FactoryAIDroidAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) ([]string, int, error) { lines, currentPos, err := ParseDroidTranscript(path, startOffset) if err != nil { return nil, 0, fmt.Errorf("failed to parse transcript: %w", err) diff --git a/cmd/entire/cli/agent/generate_external_test.go b/cmd/entire/cli/agent/generate_external_test.go index d794c4bae4..d262cffc7d 100644 --- a/cmd/entire/cli/agent/generate_external_test.go +++ b/cmd/entire/cli/agent/generate_external_test.go @@ -57,6 +57,9 @@ func TestGenerateText_PromptViaStdin(t *testing.T) { agent: &cursor.CursorAgent{}, requiredFlags: []string{"--print", "--force", "--trust", "--workspace"}, }, + // antigravity is deliberately absent: agy 1.2.x ignores stdin in print + // mode, so its prompt travels in argv. That contract is pinned in the + // antigravity package (TestGenerateText_PassesPromptInArgv). } for _, tt := range tests { diff --git a/cmd/entire/cli/agent/hook_command.go b/cmd/entire/cli/agent/hook_command.go index 505715e0bd..f89b4350f0 100644 --- a/cmd/entire/cli/agent/hook_command.go +++ b/cmd/entire/cli/agent/hook_command.go @@ -167,6 +167,24 @@ func WrapWindowsProductionSilentHookCommand(command string) string { ) } +// WrapWindowsProductionSilentHookCommandDirect is the silent wrapper for a host +// that already runs every hook command through cmd.exe /C on Windows (agy does; +// see HookHostIsWindows): the bare `where … & if errorlevel 1 (ver>nul) else +// ()` line, with no cmd.exe prefix and no quoted block. Handing such a +// host WrapWindowsProductionSilentHookCommand's nested form fails outright — +// cmd.exe /C takes the quoted block as one program name ('"where.exe entire +// >nul 2>nul & …"' is not recognized as an internal or external command) — and +// the sh wrapper fails too, because cmd.exe reads its `>/dev/null` as a redirect +// to a nonexistent path. Both were observed on Windows 11 with agy 1.2.7; only +// this shape produced a tracked session. Same distinction +// WrapWindowsProductionJSONWarningHookCommand draws for Codex. +func WrapWindowsProductionSilentHookCommandDirect(command string) string { + return fmt.Sprintf( + `where.exe entire >nul 2>nul & if errorlevel 1 (ver>nul) else (%s)`, + command, + ) +} + // WrapWindowsProductionJSONWarningHookCommand emits a JSON hook response with a // systemMessage field on stdout when the Entire CLI is missing from PATH. It // avoids sh so Codex hooks still work from native Windows shells. Codex already diff --git a/cmd/entire/cli/agent/hook_command_test.go b/cmd/entire/cli/agent/hook_command_test.go index bfc3c7a433..036fc786e0 100644 --- a/cmd/entire/cli/agent/hook_command_test.go +++ b/cmd/entire/cli/agent/hook_command_test.go @@ -369,3 +369,25 @@ func TestWrapProductionPlainTextWarningHookCommandForOS(t *testing.T) { t.Fatalf("windows wrapper not recognised as a managed hook command: %q", windows) } } + +func TestIsManagedHookCommand_RecognisesDirectWindowsSilentWrapper(t *testing.T) { + // No t.Parallel(): sibling tests in this file mutate the package-level OS seam. + cmd := WrapWindowsProductionSilentHookCommandDirect("entire hooks antigravity stop") + if !strings.HasPrefix(cmd, windowsProductionHookWrapperPrefix) { + t.Fatalf("direct wrapper must start with the bare Windows prefix, got %q", cmd) + } + if strings.HasPrefix(cmd, "cmd.exe") { + t.Fatalf("direct wrapper must not nest a cmd.exe invocation, got %q", cmd) + } + if !IsManagedHookCommand(cmd) { + t.Fatalf("IsManagedHookCommand must recognise the direct Windows wrapper: %q", cmd) + } + kept, dropped := DropStaleManagedHooks([]string{cmd}, func(s string) string { return s }, []string{cmd}) + if dropped || len(kept) != 1 { + t.Fatalf("a wanted direct-wrapper command must survive DropStaleManagedHooks, kept=%v dropped=%v", kept, dropped) + } + _, dropped = DropStaleManagedHooks([]string{cmd}, func(s string) string { return s }, nil) + if !dropped { + t.Fatal("an unwanted direct-wrapper command must be dropped as Entire's own") + } +} diff --git a/cmd/entire/cli/agent/opencode/transcript.go b/cmd/entire/cli/agent/opencode/transcript.go index 3136ed11ae..c36ef074c7 100644 --- a/cmd/entire/cli/agent/opencode/transcript.go +++ b/cmd/entire/cli/agent/opencode/transcript.go @@ -1,6 +1,7 @@ package opencode import ( + "context" "encoding/json" "fmt" "os" @@ -91,7 +92,7 @@ func (a *OpenCodeAgent) GetTranscriptPosition(path string) (int, error) { } // ExtractModifiedFilesFromOffset extracts files modified by tool calls from the given message offset. -func (a *OpenCodeAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { +func (a *OpenCodeAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) ([]string, int, error) { session, err := parseExportSessionFromFile(path) if err != nil { if os.IsNotExist(err) { diff --git a/cmd/entire/cli/agent/opencode/transcript_test.go b/cmd/entire/cli/agent/opencode/transcript_test.go index ab009b0882..43fcd43c02 100644 --- a/cmd/entire/cli/agent/opencode/transcript_test.go +++ b/cmd/entire/cli/agent/opencode/transcript_test.go @@ -152,7 +152,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { path := writeTestTranscript(t, testExportJSON) // From offset 0 — should get both main.go and util.go - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -164,7 +164,7 @@ func TestExtractModifiedFilesFromOffset(t *testing.T) { } // From offset 2 — should only get util.go (messages 3 and 4) - files, pos, err = ag.ExtractModifiedFilesFromOffset(path, 2) + files, pos, err = ag.ExtractModifiedFilesFromOffset(context.Background(), path, 2) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -375,7 +375,7 @@ func TestExtractModifiedFilesFromOffset_ApplyPatch(t *testing.T) { path := writeTestTranscript(t, testApplyPatchExportJSON) // From offset 0 — should find layout.py and resize.py (deduplicated) - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -387,7 +387,7 @@ func TestExtractModifiedFilesFromOffset_ApplyPatch(t *testing.T) { } // From offset 2 — should find layout.py and resize.py from msg-4 - files, _, err = ag.ExtractModifiedFilesFromOffset(path, 2) + files, _, err = ag.ExtractModifiedFilesFromOffset(context.Background(), path, 2) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -457,7 +457,7 @@ func TestExtractModifiedFilesFromOffset_CamelCaseFilePath(t *testing.T) { ag := &OpenCodeAgent{} path := writeTestTranscript(t, testCamelCaseExportJSON) - files, pos, err := ag.ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := ag.ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -473,7 +473,7 @@ func TestExtractModifiedFilesFromOffset_CamelCaseFilePath(t *testing.T) { } // From offset 2 — should still find the edit in msg-4 - files, _, err = ag.ExtractModifiedFilesFromOffset(path, 2) + files, _, err = ag.ExtractModifiedFilesFromOffset(context.Background(), path, 2) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/cmd/entire/cli/agent/pi/transcript.go b/cmd/entire/cli/agent/pi/transcript.go index f1c415e7f5..b02bc2db73 100644 --- a/cmd/entire/cli/agent/pi/transcript.go +++ b/cmd/entire/cli/agent/pi/transcript.go @@ -1,6 +1,7 @@ package pi import ( + "context" "encoding/json" "fmt" "os" @@ -79,7 +80,7 @@ func (a *PiAgent) GetTranscriptPosition(path string) (int, error) { // onward and returns file paths touched by file-modifying tools (`write`, // `edit`). Branch-aware: only counts entries on the active conversation // branch. -func (a *PiAgent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { +func (a *PiAgent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) ([]string, int, error) { if path == "" { return nil, 0, nil } diff --git a/cmd/entire/cli/agent/pi/transcript_test.go b/cmd/entire/cli/agent/pi/transcript_test.go index 6d8846ed78..897c25c001 100644 --- a/cmd/entire/cli/agent/pi/transcript_test.go +++ b/cmd/entire/cli/agent/pi/transcript_test.go @@ -96,7 +96,7 @@ func writeBranchingSession(t *testing.T) string { func TestExtractModifiedFiles(t *testing.T) { t.Parallel() path := writeTestSession(t) - files, pos, err := (&PiAgent{}).ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := (&PiAgent{}).ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatal(err) } @@ -111,7 +111,7 @@ func TestExtractModifiedFiles(t *testing.T) { func TestExtractModifiedFiles_OffsetPastEnd(t *testing.T) { t.Parallel() path := writeTestSession(t) - files, _, err := (&PiAgent{}).ExtractModifiedFilesFromOffset(path, 100) + files, _, err := (&PiAgent{}).ExtractModifiedFilesFromOffset(context.Background(), path, 100) if err != nil { t.Fatal(err) } @@ -123,7 +123,7 @@ func TestExtractModifiedFiles_OffsetPastEnd(t *testing.T) { func TestExtractModifiedFiles_Branching(t *testing.T) { t.Parallel() path := writeBranchingSession(t) - files, _, err := (&PiAgent{}).ExtractModifiedFilesFromOffset(path, 0) + files, _, err := (&PiAgent{}).ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatal(err) } diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 42e2dfa931..836b9812db 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -154,6 +154,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" @@ -165,6 +166,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/session_store.go b/cmd/entire/cli/agent/session_store.go index a7debf7aa8..4a37da0e1b 100644 --- a/cmd/entire/cli/agent/session_store.go +++ b/cmd/entire/cli/agent/session_store.go @@ -407,28 +407,59 @@ func (s *SessionStore) WriteFile(name string, data []byte, perm os.FileMode) err return jsonutil.WriteFileAtomicIn(root, name, data, perm) //nolint:wrapcheck // preserved for os.IsNotExist at call sites } -// Exists reports whether name is present in the store. Lstat, not Stat: a -// dangling symlink is still a file that exists and must not be overwritten -// silently (see the rewind restore path, which distinguishes the two). -func (s *SessionStore) Exists(name string) bool { +// Lstat returns the FileInfo for name inside the store without following a +// symlink at any component. The leaf is returned as-is, so a caller can tell a +// symlink from a regular file and refuse it. A missing name is reported +// unwrapped for os.IsNotExist / errors.Is(err, fs.ErrNotExist). +func (s *SessionStore) Lstat(name string) (os.FileInfo, error) { root, err := s.openRoot() if err != nil { - return false + return nil, err + } + defer root.Close() + return osroot.LstatNoSymlinks(root, name) //nolint:wrapcheck // preserved for os.IsNotExist at call sites +} + +// CreateExclusive creates name as an empty file inside the store, creating +// parent directories, and fails when anything already exists there — the +// error wraps fs.ErrExist so callers can treat "the agent wrote it first" as +// success. A symlink in any component, including the leaf, is refused rather +// than followed. It exists for the late-transcript agents that must +// materialise a placeholder without ever replacing a file the agent has since +// written, which an atomic rename would do. +func (s *SessionStore) CreateExclusive(name string, perm os.FileMode) error { + root, err := s.openRootForWrite() + if err != nil { + return err } defer root.Close() - _, err = osroot.LstatNoSymlinks(root, name) + if dir := filepath.ToSlash(filepath.Dir(filepath.FromSlash(name))); dir != "." { + if err := osroot.MkdirAllNoSymlink(root, dir, 0o700); err != nil { + return fmt.Errorf("create session directory: %w", err) + } + } + f, err := osroot.OpenFileNoFollow(root, name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) + if err != nil { + return err //nolint:wrapcheck // preserved for errors.Is(err, fs.ErrExist) at call sites + } + if err := f.Close(); err != nil { + return fmt.Errorf("close session file: %w", err) + } + return nil +} + +// Exists reports whether name is present in the store. Lstat, not Stat: a +// dangling symlink is still a file that exists and must not be overwritten +// silently (see the rewind restore path, which distinguishes the two). +func (s *SessionStore) Exists(name string) bool { + _, err := s.Lstat(name) return err == nil } // IsDir reports whether name is a real directory in the store. Symlinks in the // path are rejected by LstatNoSymlinks rather than followed. func (s *SessionStore) IsDir(name string) bool { - root, err := s.openRoot() - if err != nil { - return false - } - defer root.Close() - info, err := osroot.LstatNoSymlinks(root, name) + info, err := s.Lstat(name) return err == nil && info.IsDir() } diff --git a/cmd/entire/cli/agent/session_store_test.go b/cmd/entire/cli/agent/session_store_test.go index 1ddfece1c8..58b6ef3a6c 100644 --- a/cmd/entire/cli/agent/session_store_test.go +++ b/cmd/entire/cli/agent/session_store_test.go @@ -2,6 +2,7 @@ package agent_test import ( "fmt" + "io/fs" "os" "path/filepath" "testing" @@ -282,6 +283,55 @@ func TestSessionStore_ProbingManyDirectoriesRetainsNoDescriptors(t *testing.T) { "probing %d candidate directories must not retain a descriptor per directory", candidates) } +func TestSessionStore_LstatRefusesSymlinkedParentAndReportsLeaf(t *testing.T) { + t.Parallel() + store, dir := newStore(t, joinResolve) + + require.NoError(t, os.WriteFile(filepath.Join(dir, "real.jsonl"), []byte("x"), 0o600)) + info, err := store.Lstat("real.jsonl") + require.NoError(t, err) + assert.Equal(t, int64(1), info.Size()) + + _, err = store.Lstat("missing.jsonl") + assert.True(t, os.IsNotExist(err), "a missing name must classify as not-exist, got %v", err) + + if err := os.Symlink(filepath.Join(dir, "real.jsonl"), filepath.Join(dir, "link.jsonl")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + info, err = store.Lstat("link.jsonl") + require.NoError(t, err) + assert.NotZero(t, info.Mode()&os.ModeSymlink, "the leaf is returned as-is so the caller can refuse it") + + elsewhere := t.TempDir() + require.NoError(t, os.Symlink(elsewhere, filepath.Join(dir, "sub"))) + _, err = store.Lstat("sub/anything.jsonl") + require.Error(t, err, "a symlinked parent component must be refused") +} + +func TestSessionStore_CreateExclusiveCreatesOnceAndNeverReplaces(t *testing.T) { + t.Parallel() + store, dir := newStore(t, joinResolve) + + require.NoError(t, store.CreateExclusive("conv/logs/transcript.jsonl", 0o600)) + info, err := os.Stat(filepath.Join(dir, "conv", "logs", "transcript.jsonl")) + require.NoError(t, err) + assert.Equal(t, int64(0), info.Size()) + + // The agent wrote the real file in between: a second create must fail with + // fs.ErrExist and leave the content alone. + require.NoError(t, os.WriteFile(filepath.Join(dir, "conv", "logs", "transcript.jsonl"), []byte("real"), 0o600)) + err = store.CreateExclusive("conv/logs/transcript.jsonl", 0o600) + require.ErrorIs(t, err, fs.ErrExist) + data, err := os.ReadFile(filepath.Join(dir, "conv", "logs", "transcript.jsonl")) + require.NoError(t, err) + assert.Equal(t, "real", string(data)) + + if err := os.Symlink(t.TempDir(), filepath.Join(dir, "linked")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + require.Error(t, store.CreateExclusive("linked/transcript.jsonl", 0o600), "a symlinked parent must be refused") +} + // The store itself may not exist yet — an external plugin is allowed to create // it — so the walk has to resolve a symlinked ANCESTOR while the store and the // reference below it are both still missing. @@ -309,18 +359,35 @@ func TestSessionStore_FollowsSymlinkedAncestorOfAMissingStore(t *testing.T) { } // Nested directories inherit the store root's 0700 rather than 0750: they hold -// the same transcripts, and Copilot, Cursor and Codex all write into them. +// the same transcripts, and Copilot, Cursor and Codex all write into them. Both +// writers that create those directories are covered: CreateExclusive reaches +// the same tree as WriteFile, through the late-transcript placeholder. func TestSessionStore_CreatesNestedDirectories0700(t *testing.T) { t.Parallel() - storeDir := filepath.Join(t.TempDir(), "store") - store, err := agent.OpenSessionStoreAt(&storeStubAgent{dir: storeDir, resolve: joinResolve}, storeDir) - require.NoError(t, err) - require.NoError(t, store.WriteFile("nested/deeper/session.jsonl", []byte("hi\n"), 0o600)) + writers := map[string]func(*agent.SessionStore) error{ + "WriteFile": func(s *agent.SessionStore) error { + return s.WriteFile("nested/deeper/session.jsonl", []byte("hi\n"), 0o600) + }, + "CreateExclusive": func(s *agent.SessionStore) error { + return s.CreateExclusive("nested/deeper/session.jsonl", 0o600) + }, + } - for _, dir := range []string{storeDir, filepath.Join(storeDir, "nested"), filepath.Join(storeDir, "nested", "deeper")} { - info, err := os.Stat(dir) - require.NoError(t, err, dir) - assert.Equal(t, os.FileMode(0o700), info.Mode().Perm(), dir) + for name, write := range writers { + t.Run(name, func(t *testing.T) { + t.Parallel() + + storeDir := filepath.Join(t.TempDir(), "store") + store, err := agent.OpenSessionStoreAt(&storeStubAgent{dir: storeDir, resolve: joinResolve}, storeDir) + require.NoError(t, err) + require.NoError(t, write(store)) + + for _, dir := range []string{storeDir, filepath.Join(storeDir, "nested"), filepath.Join(storeDir, "nested", "deeper")} { + info, err := os.Stat(dir) + require.NoError(t, err, dir) + assert.Equal(t, os.FileMode(0o700), info.Mode().Perm(), dir) + } + }) } } diff --git a/cmd/entire/cli/agent/skilldiscovery/registry.go b/cmd/entire/cli/agent/skilldiscovery/registry.go index 4a7cbb88c4..44fa17c444 100644 --- a/cmd/entire/cli/agent/skilldiscovery/registry.go +++ b/cmd/entire/cli/agent/skilldiscovery/registry.go @@ -74,6 +74,18 @@ var installHints = map[string][]InstallHint{ ProvidesAny: []string{"$codex:adversarial-review"}, }, }, + // Antigravity has no built-in review command and no predictable plugin + // skill names, so the hint is always shown (ProvidesAny nil). The path is + // agy 1.1+'s global skills root; ~/.gemini/skills is a pre-1.1 layout (see + // antigravity/discovery.go). Entire scans both, so a skill placed at the + // old path still reaches Entire's prompt — but agy itself will not load + // it, which is the half a hint pointing there would silently get wrong. + "antigravity": { + { + Message: "Add a review skill under ~/.gemini/config/skills//SKILL.md, e.g.: npx antigravity-awesome-skills --agy", + ProvidesAny: nil, + }, + }, } // CuratedBuiltinsFor returns the curated built-in list for agentName, or diff --git a/cmd/entire/cli/agent/skilldiscovery/registry_test.go b/cmd/entire/cli/agent/skilldiscovery/registry_test.go index b6c750b75a..2c9a6d1b1a 100644 --- a/cmd/entire/cli/agent/skilldiscovery/registry_test.go +++ b/cmd/entire/cli/agent/skilldiscovery/registry_test.go @@ -48,6 +48,21 @@ func TestActiveInstallHintsFor_ShowsAllWhenNothingDiscovered(t *testing.T) { } } +func TestActiveInstallHintsFor_AntigravityAlwaysShownRegardlessOfDiscovery(t *testing.T) { + t.Parallel() + hints := skilldiscovery.ActiveInstallHintsFor("antigravity", map[string]struct{}{"/code-review": {}}) + if len(hints) == 0 { + t.Error("antigravity hint with nil ProvidesAny should always show") + } +} + +func TestIsEligible_IncludesAntigravity(t *testing.T) { + t.Parallel() + if !skilldiscovery.IsEligible("antigravity") { + t.Error("antigravity should be eligible via install hint") + } +} + func TestIsEligible_IncludesAgentWithOnlyInstallHint(t *testing.T) { t.Parallel() // Codex has no curated built-ins, only an install hint. diff --git a/cmd/entire/cli/agent/skilldiscovery/scan_test.go b/cmd/entire/cli/agent/skilldiscovery/scan_test.go new file mode 100644 index 0000000000..a4d0b67dab --- /dev/null +++ b/cmd/entire/cli/agent/skilldiscovery/scan_test.go @@ -0,0 +1,71 @@ +package skilldiscovery + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func writeSkillMD(t *testing.T, root, name, contents string) { + t.Helper() + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(contents), 0o600); err != nil { + t.Fatalf("write: %v", err) + } +} + +func TestScanSkillsDir_FindsReviewSkillsAndSkipsRest(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeSkillMD(t, root, "code-review", "---\nname: code-review\ndescription: Review a PR.\n---\nbody") + writeSkillMD(t, root, "formatter", "---\nname: formatter\ndescription: Format code.\n---\nbody") + writeSkillMD(t, root, "broken", "no frontmatter here") + + got := ScanSkillsDir(context.Background(), root, "", SlashForm) + if len(got) != 1 { + t.Fatalf("got %d skills, want 1: %+v", len(got), got) + } + if got[0].Name != "/code-review" { + t.Errorf("Name = %q, want /code-review", got[0].Name) + } + if got[0].Description != "Review a PR." { + t.Errorf("Description = %q, want %q", got[0].Description, "Review a PR.") + } + if got[0].SourcePath == "" { + t.Error("SourcePath should be populated") + } +} + +func TestScanSkillsDir_FallsBackToDirNameWhenNoNameField(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeSkillMD(t, root, "security-audit", "---\ndescription: Audit deps.\n---\nbody") + + got := ScanSkillsDir(context.Background(), root, "", SlashForm) + if len(got) != 1 || got[0].Name != "/security-audit" { + t.Fatalf("want /security-audit from dir name, got %+v", got) + } +} + +func TestScanSkillsDir_PluginPrefix(t *testing.T) { + t.Parallel() + root := t.TempDir() + writeSkillMD(t, root, "hunter", "---\nname: hunter\ndescription: x.\n---\nbody") + + got := ScanSkillsDir(context.Background(), root, "pr-review-toolkit", SlashForm) + if len(got) != 1 || got[0].Name != "/pr-review-toolkit:hunter" { + t.Fatalf("want /pr-review-toolkit:hunter (matches via plugin prefix), got %+v", got) + } +} + +func TestScanSkillsDir_MissingDirReturnsNil(t *testing.T) { + t.Parallel() + got := ScanSkillsDir(context.Background(), filepath.Join(t.TempDir(), "nope"), "", SlashForm) + if got != nil { + t.Errorf("want nil for missing dir, got %+v", got) + } +} diff --git a/cmd/entire/cli/agent/text_generator_cli.go b/cmd/entire/cli/agent/text_generator_cli.go index 922d241a80..c8841cf47c 100644 --- a/cmd/entire/cli/agent/text_generator_cli.go +++ b/cmd/entire/cli/agent/text_generator_cli.go @@ -108,12 +108,13 @@ func RunIsolatedTextGeneratorCLI(ctx context.Context, runner TextCommandRunner, const openCodeBinary = "opencode" var summaryProviderBinaries = map[types.AgentName]string{ - AgentNameClaudeCode: "claude", - AgentNameCodex: "codex", - AgentNameCopilotCLI: "copilot", - AgentNameCursor: "agent", - AgentNamePi: "pi", - AgentNameOpenCode: openCodeBinary, + AgentNameAntigravity: "agy", + AgentNameClaudeCode: "claude", + AgentNameCodex: "codex", + AgentNameCopilotCLI: "copilot", + AgentNameCursor: "agent", + AgentNamePi: "pi", + AgentNameOpenCode: openCodeBinary, } // SummaryCLIBinaryName returns the CLI binary name for a summary-capable diff --git a/cmd/entire/cli/agent/transcript_read_guard_test.go b/cmd/entire/cli/agent/transcript_read_guard_test.go index 5a12cf9a08..74babf6955 100644 --- a/cmd/entire/cli/agent/transcript_read_guard_test.go +++ b/cmd/entire/cli/agent/transcript_read_guard_test.go @@ -34,6 +34,7 @@ const transcriptReadPattern = `os\.(ReadFile|Open)\((sessionRef|transcriptPath)\ // nothing while looking like it does. See // docs/development/filesystem-safety.md#the-root-anchors. var unconfinedTranscriptReads = map[string]int{ + "cmd/entire/cli/agent/antigravity/transcript.go": 1, "cmd/entire/cli/agent/claudecode/lifecycle.go": 1, "cmd/entire/cli/agent/codex/codex.go": 1, "cmd/entire/cli/agent/codex/transcript.go": 1, diff --git a/cmd/entire/cli/agent/vogon/transcript.go b/cmd/entire/cli/agent/vogon/transcript.go index ebe8acafb3..523879921d 100644 --- a/cmd/entire/cli/agent/vogon/transcript.go +++ b/cmd/entire/cli/agent/vogon/transcript.go @@ -2,6 +2,7 @@ package vogon import ( "bufio" + "context" "encoding/json" "fmt" "os" @@ -41,7 +42,7 @@ func (v *Agent) GetTranscriptPosition(path string) (int, error) { // appear only in its own transcript. Without it, `entire hooks vogon post-task` // would fall back to worktree state alone and the canary could not see regressions // in subagent transcript resolution or file attribution. -func (v *Agent) ExtractModifiedFilesFromOffset(path string, startOffset int) ([]string, int, error) { +func (v *Agent) ExtractModifiedFilesFromOffset(_ context.Context, path string, startOffset int) ([]string, int, error) { lines, err := readTranscriptLines(path) if err != nil { return nil, 0, err diff --git a/cmd/entire/cli/agent/vogon/transcript_test.go b/cmd/entire/cli/agent/vogon/transcript_test.go index 6639b1ab6f..97d6f34f0c 100644 --- a/cmd/entire/cli/agent/vogon/transcript_test.go +++ b/cmd/entire/cli/agent/vogon/transcript_test.go @@ -1,6 +1,7 @@ package vogon import ( + "context" "os" "path/filepath" "testing" @@ -23,7 +24,7 @@ func TestExtractModifiedFilesFromOffset_OnlyToolUseEntries(t *testing.T) { t.Fatalf("write transcript: %v", err) } - files, pos, err := (&Agent{}).ExtractModifiedFilesFromOffset(path, 0) + files, pos, err := (&Agent{}).ExtractModifiedFilesFromOffset(context.Background(), path, 0) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset: %v", err) } @@ -50,7 +51,7 @@ func TestExtractModifiedFilesFromOffset_HonoursOffsetAndDedupes(t *testing.T) { t.Fatalf("write transcript: %v", err) } - files, _, err := (&Agent{}).ExtractModifiedFilesFromOffset(path, 1) + files, _, err := (&Agent{}).ExtractModifiedFilesFromOffset(context.Background(), path, 1) if err != nil { t.Fatalf("ExtractModifiedFilesFromOffset: %v", err) } diff --git a/cmd/entire/cli/agent/vouched_dirs.go b/cmd/entire/cli/agent/vouched_dirs.go index 3ef43f35c3..daf66dbcd7 100644 --- a/cmd/entire/cli/agent/vouched_dirs.go +++ b/cmd/entire/cli/agent/vouched_dirs.go @@ -204,6 +204,7 @@ func FollowedSymlinkedDirs(worktreeRoot string) []string { // runs in a binary where every built-in agent IS registered and fails in both // directions, so a new agent that forgets this list cannot ship. var vouchableDirs = []string{ + ".agents", ".claude", ".codex", ".cursor", diff --git a/cmd/entire/cli/doctor.go b/cmd/entire/cli/doctor.go index 10cad2907f..8ab444d2cc 100644 --- a/cmd/entire/cli/doctor.go +++ b/cmd/entire/cli/doctor.go @@ -8,6 +8,7 @@ import ( "io/fs" "log/slog" "os" + "os/exec" "path" "path/filepath" "slices" @@ -16,6 +17,7 @@ import ( "charm.land/huh/v2" "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/codex" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint" @@ -172,6 +174,12 @@ func runSessionsFix(cmd *cobra.Command, force bool) error { // Agent-specific: Codex hook trust state. checkCodexHookTrust(cmd) + // Agent-specific: Antigravity title-tee (token-usage surface). + checkAntigravityTitleTee(cmd) + + // Agent-specific: does agy actually load the workspace hooks? + checkAntigravityHooksLoaded(cmd) + // Agent-specific: Claude Code hook config drift. checkHookDrift(cmd) @@ -1585,6 +1593,108 @@ func writeCodexHookStatus(w io.Writer, diagnostics codex.HookDiagnostics, active } } +// antigravityDoctorSubject gates both Antigravity checks the same way: they +// only apply where Entire's Antigravity hooks are installed and switched on +// AND agy is on PATH. A teammate's checkout can carry the hooks on a machine +// that never uses agy; reporting there would be a false positive. One gate, +// evaluated once. +// +// The "enabled": false check is here rather than in AreHooksInstalled because +// that predicate also drives agent auto-detection and `entire agent list`, +// where an entry the user switched off is still genuinely present. Only +// doctor's advice is unwanted for a configuration nobody asked to run. +func antigravityDoctorSubject(cmd *cobra.Command) (*antigravity.AntigravityAgent, bool) { + ag := &antigravity.AntigravityAgent{} + installed, err := ag.AreHooksInstalled(cmd.Context()) + if err != nil || !installed { + return nil, false + } + if disabled, err := ag.HooksDisabled(cmd.Context()); err != nil || disabled { + return nil, false + } + if _, err := exec.LookPath("agy"); err != nil { + return nil, false + } + return ag, true +} + +// checkAntigravityTitleTee warns when Antigravity hooks are installed in this +// repo but agy's global title slot — agy's only token-usage surface — is not +// routed through Entire, which leaves token counts missing from checkpoints. +// Warn-only. +func checkAntigravityTitleTee(cmd *cobra.Command) { + if _, ok := antigravityDoctorSubject(cmd); !ok { + return + } + w := cmd.OutOrStdout() + if antigravity.TitleTeeInstalled() { + fmt.Fprintln(w, "✓ Antigravity title-tee: OK") + return + } + + fmt.Fprintln(w, "Antigravity title-tee: NOT CONFIGURED") + fmt.Fprintln(w, " agy's title command isn't routed through Entire, so token counts") + fmt.Fprintln(w, " will be missing for Antigravity checkpoints.") + fmt.Fprintln(w, " Re-run agent setup (`entire agent add antigravity`) to configure it.") +} + +// checkAntigravityHooksLoaded reports two things about the installed hooks. +// +// Always, at zero cost: whether the "entire" entry in .agents/hooks.json is +// the one this host needs. The command's shape is host-specific — agy runs it +// through cmd.exe on Windows and sh elsewhere — and a file committed from a +// macOS checkout carries a sh wrapper that cmd.exe tears apart: the hook exits +// 1, the failure shows only in agy's log, the turn reports SUCCESS and nothing +// is tracked. The file being present said nothing about that, and a green +// doctor over zero tracked sessions is worse than no check. +// +// Only when ENTIRE_ANTIGRAVITY_DOCTOR_PROBE=1: ask agy itself whether it loads +// this workspace's hooks (`agy -p /hooks --add-dir `), which catches the +// untrusted-workspace trap. Opt-in because agy 1.2.7 on Windows was observed +// to answer that with a full model turn, and the probe must never spend the +// user's quota by default. Warn-only throughout. +func checkAntigravityHooksLoaded(cmd *cobra.Command) { + ag, ok := antigravityDoctorSubject(cmd) + if !ok { + return + } + w := cmd.OutOrStdout() + + if installed, current, err := ag.HooksEntryMatchesHost(cmd.Context()); err == nil && installed && !current { + fmt.Fprintln(w, "Antigravity hooks: STALE FOR THIS HOST") + fmt.Fprintln(w, " The \"entire\" entry in .agents/hooks.json is not the command this host") + fmt.Fprintln(w, " needs (agy runs hooks through cmd.exe on Windows and sh elsewhere), so") + fmt.Fprintln(w, " the hooks fail silently and nothing is tracked.") + fmt.Fprintln(w, " Re-run `entire agent add antigravity` to reinstall them for this host.") + } + + if os.Getenv(antigravity.DoctorProbeEnv) == "" { + return + } + repoRoot, err := paths.WorktreeRoot(cmd.Context()) + if err != nil { + return + } + probe, err := antigravity.ProbeLoadedHooks(cmd.Context(), repoRoot) + switch { + case errors.Is(err, antigravity.ErrHooksProbeVersionUnknown): + fmt.Fprintf(w, "Antigravity hooks: NOT VERIFIED (could not determine the agy version from %q; skipping the `/hooks` probe)\n", + probe.Version) + case errors.Is(err, antigravity.ErrHooksProbeUnsupported): + fmt.Fprintf(w, "Antigravity hooks: NOT VERIFIED (agy %s is too old to answer `/hooks` headlessly; %s+ needed — run `agy update`)\n", + probe.Version, antigravity.MinHooksProbeVersion) + case err != nil: + fmt.Fprintf(w, "Antigravity hooks: NOT VERIFIED (%v)\n", err) + case probe.Loaded: + fmt.Fprintln(w, "✓ Antigravity hooks: LOADED by agy") + default: + fmt.Fprintln(w, "Antigravity hooks: NOT LOADED by agy") + fmt.Fprintln(w, " .agents/hooks.json exists but agy does not load it for this workspace,") + fmt.Fprintln(w, " so Entire's hooks never fire. Trust the folder in an interactive `agy`") + fmt.Fprintln(w, " session, and pass `--add-dir ` to `agy -p` runs.") + } +} + func writeCodexInactiveWorktreeWarning(w io.Writer, worktreePath, discoveredPath string) { fmt.Fprintln(w, "Codex hooks: NOT ACTIVE IN THIS WORKTREE") fmt.Fprintln(w, " Entire hooks are configured at the current-worktree path:") diff --git a/cmd/entire/cli/doctor_test.go b/cmd/entire/cli/doctor_test.go index b5c3246310..76b7939217 100644 --- a/cmd/entire/cli/doctor_test.go +++ b/cmd/entire/cli/doctor_test.go @@ -13,6 +13,7 @@ import ( "time" "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/checkpoint" @@ -1351,6 +1352,102 @@ trusted_hash = "sha256:ccc" require.NotContains(t, out, "Codex hook trust: REVIEW NEEDED") } +// antigravityHooksJSON returns a minimal .agents/hooks.json declaring the +// Entire PreInvocation hook, enough for AreHooksInstalled to report true. +func antigravityHooksJSON() string { + return `{"entire":{"PreInvocation":[{"type":"command","command":"entire hooks antigravity pre-invocation"}]}}` +} + +// stubAgyOnPath prepends a directory containing a fake executable `agy` to +// PATH so the doctor check's binary-presence guard passes deterministically. +func stubAgyOnPath(t *testing.T) { + t.Helper() + binDir := t.TempDir() + stub := filepath.Join(binDir, "agy") + require.NoError(t, os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0o755)) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +// TestCheckAntigravityTitleTee_SilentWhenAgyNotInstalled stays quiet for +// developers who don't use agy at all: .agents/hooks.json is committable, so +// a teammate's checkout can have Antigravity hooks "installed" on a machine +// with no agy binary — warning there (and suggesting a repair that writes +// agy's global settings) is a false positive. +func TestCheckAntigravityTitleTee_SilentWhenAgyNotInstalled(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + + agentsDir := filepath.Join(dir, ".agents") + require.NoError(t, os.MkdirAll(agentsDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(agentsDir, "hooks.json"), + []byte(antigravityHooksJSON()), 0o600)) + t.Setenv("ENTIRE_ANTIGRAVITY_CONFIG_DIR", filepath.Join(t.TempDir(), "agy")) + t.Setenv("PATH", t.TempDir()) // no agy binary anywhere on PATH + + cmd, stdout := newTestCmd(t) + checkAntigravityTitleTee(cmd) + require.NotContains(t, stdout.String(), "Antigravity title-tee") +} + +// TestCheckAntigravityTitleTee_SilentWhenHooksNotInstalled stays quiet when +// the repo has no Antigravity hooks — nothing to check. +func TestCheckAntigravityTitleTee_SilentWhenHooksNotInstalled(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv("ENTIRE_ANTIGRAVITY_CONFIG_DIR", filepath.Join(t.TempDir(), "agy")) + + cmd, stdout := newTestCmd(t) + checkAntigravityTitleTee(cmd) + require.NotContains(t, stdout.String(), "Antigravity title-tee") +} + +// TestCheckAntigravityTitleTee_OKWhenConfigured reports OK when hooks are +// installed and agy's title slot routes through the title-tee shim. +func TestCheckAntigravityTitleTee_OKWhenConfigured(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + stubAgyOnPath(t) + + agentsDir := filepath.Join(dir, ".agents") + require.NoError(t, os.MkdirAll(agentsDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(agentsDir, "hooks.json"), + []byte(antigravityHooksJSON()), 0o600)) + + cfgDir := filepath.Join(t.TempDir(), "agy") + require.NoError(t, os.MkdirAll(cfgDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(cfgDir, "settings.json"), + []byte(`{"title":{"type":"command","command":"entire hooks antigravity title-tee"}}`), 0o600)) + t.Setenv("ENTIRE_ANTIGRAVITY_CONFIG_DIR", cfgDir) + + cmd, stdout := newTestCmd(t) + checkAntigravityTitleTee(cmd) + require.Contains(t, stdout.String(), "✓ Antigravity title-tee: OK") +} + +// TestCheckAntigravityTitleTee_WarnsWhenNotConfigured surfaces the missing +// token-usage surface when hooks are installed but the title slot is unclaimed. +func TestCheckAntigravityTitleTee_WarnsWhenNotConfigured(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + stubAgyOnPath(t) + + agentsDir := filepath.Join(dir, ".agents") + require.NoError(t, os.MkdirAll(agentsDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(agentsDir, "hooks.json"), + []byte(antigravityHooksJSON()), 0o600)) + + // Empty agy config dir — no title slot claimed. + t.Setenv("ENTIRE_ANTIGRAVITY_CONFIG_DIR", filepath.Join(t.TempDir(), "agy")) + + cmd, stdout := newTestCmd(t) + checkAntigravityTitleTee(cmd) + + out := stdout.String() + require.Contains(t, out, "Antigravity title-tee: NOT CONFIGURED") + require.Contains(t, out, "token counts") + require.Contains(t, out, "entire agent add antigravity") +} + // TestConfirmDoctorFix_CancelledContext verifies that a cancelled command // context makes the confirm prompt return (false, nil) rather than surfacing a // wrapped error — doctor fixes are skipped cleanly on interrupt. @@ -1480,6 +1577,90 @@ func TestCheckDisconnectedMetadata_Aligned_StaysQuiet(t *testing.T) { assert.NotContains(t, output, "DIVERGED") } +// stubAgyHooksProbeOnPath installs a fake agy that answers `--version` with +// version and `-p /hooks` with a JSON envelope listing hooksSource as an +// enabled "entire" entry (or no hooks when hooksSource is empty). +func stubAgyHooksProbeOnPath(t *testing.T, version, hooksSource string) { + t.Helper() + binDir := t.TempDir() + hooks := "[]" + if hooksSource != "" { + hooks = `[{"name":"entire","enabled":true,"source":"` + hooksSource + `"}]` + } + script := "#!/bin/sh\n" + + "case \"$1\" in\n" + + " --version) echo '" + version + "' ;;\n" + + " -p) printf '%s' '{\"status\":\"SUCCESS\",\"command\":{\"name\":\"hooks\",\"data\":{\"hooks\":" + hooks + "}}}' ;;\n" + + " *) exit 0 ;;\n" + + "esac\n" + require.NoError(t, os.WriteFile(filepath.Join(binDir, "agy"), []byte(script), 0o755)) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) +} + +func writeAntigravityHooksForDoctor(t *testing.T, dir string) string { + t.Helper() + agentsDir := filepath.Join(dir, ".agents") + require.NoError(t, os.MkdirAll(agentsDir, 0o750)) + hooksPath := filepath.Join(agentsDir, "hooks.json") + require.NoError(t, os.WriteFile(hooksPath, []byte(antigravityHooksJSON()), 0o600)) + return hooksPath +} + +func TestCheckAntigravityHooksLoaded_OKWhenAgyListsWorkspaceHooks(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv(antigravity.DoctorProbeEnv, "1") + hooksPath := writeAntigravityHooksForDoctor(t, dir) + stubAgyHooksProbeOnPath(t, "1.1.22", hooksPath) + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.Contains(t, stdout.String(), "✓ Antigravity hooks: LOADED by agy") +} + +func TestCheckAntigravityHooksLoaded_WarnsWhenAgyDoesNotLoadThem(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv(antigravity.DoctorProbeEnv, "1") + writeAntigravityHooksForDoctor(t, dir) + stubAgyHooksProbeOnPath(t, "1.1.22", "") + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.Contains(t, stdout.String(), "Antigravity hooks: NOT LOADED by agy") + require.Contains(t, stdout.String(), "--add-dir") +} + +// TestCheckAntigravityHooksLoaded_SkipsOldAgy pins the quota guard: before +// 1.1.12, `agy -p "/hooks"` is a real model turn, so doctor must not run it. +func TestCheckAntigravityHooksLoaded_SkipsOldAgy(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv(antigravity.DoctorProbeEnv, "1") + hooksPath := writeAntigravityHooksForDoctor(t, dir) + stubAgyHooksProbeOnPath(t, "1.1.1", hooksPath) + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.Contains(t, stdout.String(), "NOT VERIFIED") + require.Contains(t, stdout.String(), "agy update") + require.NotContains(t, stdout.String(), "LOADED by agy") +} + +func TestCheckAntigravityHooksLoaded_SilentWithoutHooksOrAgy(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv("PATH", t.TempDir()) + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.Empty(t, stdout.String()) + + writeAntigravityHooksForDoctor(t, dir) // hooks present but no agy on PATH + checkAntigravityHooksLoaded(cmd) + require.Empty(t, stdout.String()) +} + // A symlinked agent directory arrives by clone and is invisible everywhere else: // enable refuses to write through it, then HookConfigFile.Exists() reports the // config as absent, so status says hooks are missing without saying why and @@ -1954,3 +2135,71 @@ func TestCheckAgentDirSymlinks_VouchedLinkWithCleanTargetReportsOnlyTheLink(t *t assert.NotContains(t, got, "SYMLINKS PRESENT", "a clean target is not a fault") assert.NotContains(t, got, "NOT READABLE") } + +// The `/hooks` probe spends quota on at least one platform (agy 1.2.7 on +// Windows ran a full model turn for it), so a default doctor run must not +// invoke it at all — only ENTIRE_ANTIGRAVITY_DOCTOR_PROBE=1 does. +func TestCheckAntigravityHooksLoaded_ProbeIsOptIn(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + writeAntigravityHooksForDoctor(t, dir) + marker := filepath.Join(t.TempDir(), "probe-ran") + binDir := t.TempDir() + script := "#!/bin/sh\n" + + "case \"$1\" in\n" + + " --version) echo '1.2.7' ;;\n" + + " -p) : > '" + marker + "'; printf '%s' '{\"status\":\"SUCCESS\",\"command\":{\"name\":\"hooks\",\"data\":{\"hooks\":[]}}}' ;;\n" + + " *) exit 0 ;;\n" + + "esac\n" + require.NoError(t, os.WriteFile(filepath.Join(binDir, "agy"), []byte(script), 0o755)) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv(antigravity.DoctorProbeEnv, "") + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("doctor ran `agy -p /hooks` without the opt-in (stat err = %v)", err) + } + require.NotContains(t, stdout.String(), "LOADED by agy") + require.NotContains(t, stdout.String(), "NOT VERIFIED") +} + +// The zero-cost check that replaces the probe by default: an installed entry +// that is not what this host needs (here a bare command with no wrapper at all) +// is reported with the reinstall remedy, and a freshly installed one is not. +func TestCheckAntigravityHooksLoaded_ReportsAnEntryStaleForThisHost(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv("ENTIRE_ANTIGRAVITY_CONFIG_DIR", t.TempDir()) + stubAgyOnPath(t) + writeAntigravityHooksForDoctor(t, dir) + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.Contains(t, stdout.String(), "Antigravity hooks: STALE FOR THIS HOST") + require.Contains(t, stdout.String(), "entire agent add antigravity") + + // A current install is silent. + _, err := (&antigravity.AntigravityAgent{}).InstallHooks(cmd.Context(), true) + require.NoError(t, err) + cmd, stdout = newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.NotContains(t, stdout.String(), "STALE FOR THIS HOST") +} + +// A version string semver cannot parse is not "too old": the agy may be newer +// than the requirement, so the advice must not be `agy update`. +func TestCheckAntigravityHooksLoaded_UnparseableVersionSkipsProbeWithoutUpgradeAdvice(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + t.Setenv(antigravity.DoctorProbeEnv, "1") + hooksPath := writeAntigravityHooksForDoctor(t, dir) + stubAgyHooksProbeOnPath(t, "Antigravity CLI build 2026.09", hooksPath) + + cmd, stdout := newTestCmd(t) + checkAntigravityHooksLoaded(cmd) + require.Contains(t, stdout.String(), "could not determine the agy version") + require.NotContains(t, stdout.String(), "agy update") + require.NotContains(t, stdout.String(), "LOADED by agy") +} diff --git a/cmd/entire/cli/entiredir_guard_test.go b/cmd/entire/cli/entiredir_guard_test.go index 7447464f9f..ef2d4c7e38 100644 --- a/cmd/entire/cli/entiredir_guard_test.go +++ b/cmd/entire/cli/entiredir_guard_test.go @@ -48,6 +48,8 @@ var entireDirCheckExemptions = map[string]string{ "entire labs": "prints a static list of experimental workflows", "entire completion": "prints a shell script; users eval it from a shell rc, which a broken repo must not break", "entire doctor": "diagnostic; has to run ON a broken repo in order to report it", + "entire hooks antigravity title-tee": "captures agy's token counts into the per-user cache; touches no repo state, " + + "and agy fires it on every agent state change, so failing the guard would print the remedy and exit non-zero once per fire", } // collectExemptions walks the tree and returns every command path whose own diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index f6e8f60da2..e928dc84b1 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -1253,7 +1253,7 @@ func formatCheckpointSummaryError(err error, attempt *summaryAttempt) (string, [ var claudeErr *claudecode.ClaudeError switch { case errors.As(err, &claudeErr): - switch claudeErr.Kind { //nolint:exhaustive // ClaudeErrorUnknown handled by default + switch claudeErr.Kind { case claudecode.ClaudeErrorAuth: label := "Claude authentication failed" rows := []explainRow{ @@ -1284,6 +1284,8 @@ func formatCheckpointSummaryError(err error, attempt *summaryAttempt) (string, [ case claudecode.ClaudeErrorCLIMissing: label := "Claude CLI is not installed or not on PATH" return label, nil, errors.New("Claude CLI is not installed or not on PATH") //nolint:staticcheck // ST1005 + case claudecode.ClaudeErrorUnknown: + fallthrough default: label := "Claude failed to generate the summary" suffix := formatClaudeErrorSuffix(claudeErr) diff --git a/cmd/entire/cli/explain_summary_provider_test.go b/cmd/entire/cli/explain_summary_provider_test.go index 683c409733..fcd387ce61 100644 --- a/cmd/entire/cli/explain_summary_provider_test.go +++ b/cmd/entire/cli/explain_summary_provider_test.go @@ -1375,7 +1375,7 @@ func TestSummaryCapableProviderNames_MatchesTheBuiltInAgents(t *testing.T) { // factoryai-droid is deliberately absent: it is a registered agent with no // GenerateText, and naming it is the fault this feature reports. - want := []string{"claude-code", "codex", "copilot-cli", "cursor", "opencode", "pi"} + want := []string{"antigravity", "claude-code", "codex", "copilot-cli", "cursor", "opencode", "pi"} got := summaryCapableProviderNames() if !slices.Equal(got, want) { t.Errorf("summary-capable providers = %v, want %v\n"+ diff --git a/cmd/entire/cli/hook_registry.go b/cmd/entire/cli/hook_registry.go index 672b1f1891..216de46785 100644 --- a/cmd/entire/cli/hook_registry.go +++ b/cmd/entire/cli/hook_registry.go @@ -44,22 +44,15 @@ func GetCurrentHookAgent() (agent.Agent, error) { // newAgentHooksCmd creates a hooks subcommand for an agent that implements HookSupport. // It dynamically creates subcommands for each hook the agent supports. func newAgentHooksCmd(agentName types.AgentName, handler agent.HookSupport) *cobra.Command { + // No PersistentPreRun here: it would also run for every command attached + // to this one that is not a lifecycle verb. title-tee is such a command, + // and agy fires it on every state change during a turn, so the session + // scan and redactor construction ran roughly once a second per turn to + // append one JSON line. The hook session is set up per verb instead. cmd := &cobra.Command{ Use: string(agentName), Short: handler.Description() + " hook handlers", Hidden: true, - PersistentPreRun: func(cmd *cobra.Command, _ []string) { - // withHookSession scans session state and loads redactors, so it must - // not run in a repo that never enabled Entire. Same fail-closed gate - // the git-hook tree applies before its own call. - if !settings.IsSetUpAndEnabled(cmd.Context()) { - return - } - // Cobra invokes this PersistentPreRun with the leaf command, so - // SetContext hands the session-stamped context straight to the - // hook verb's RunE via cmd.Context(). - cmd.SetContext(withHookSession(cmd.Context())) - }, } for _, hookName := range handler.HookNames() { @@ -93,9 +86,10 @@ func getHookType(hookName string) string { // parsing, and lifecycle dispatch. // Used by both the registered subcommand path and the RunE fallback for external agents. // When stampSession is true, it attaches the hook session context itself (used by -// the RunE fallback since it doesn't go through PersistentPreRun). Built-in agent -// subcommands pass false since their parent command's PersistentPreRun already -// did it. +// the RunE fallback since it doesn't go through PersistentPreRun). Built-in hook +// verbs pass false because each verb's OWN PersistentPreRun already did it — not +// an inherited one: the shared per-agent command deliberately defines no hook, so +// anything else attached to it (Antigravity's title-tee) is not stamped either. func executeAgentHook(cmd *cobra.Command, agentName types.AgentName, hookName string, stampSession bool) error { // Skip silently if not in a git repository - hooks shouldn't prevent the agent from working if _, err := paths.WorktreeRoot(cmd.Context()); err != nil { @@ -195,6 +189,19 @@ func newAgentHookVerbCmdWithLogging(agentName types.AgentName, hookName string) Use: hookName, Hidden: true, Short: "Called on " + hookName, + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + // On the verb rather than the shared agent command, so only + // lifecycle verbs pay for it. withHookSession scans session state + // and loads redactors, so it must not run in a repo that never + // enabled Entire. Same fail-closed gate the git-hook tree applies + // before its own call. + if !settings.IsSetUpAndEnabled(cmd.Context()) { + return + } + // SetContext hands the session-stamped context straight to this + // command's RunE via cmd.Context(). + cmd.SetContext(withHookSession(cmd.Context())) + }, RunE: func(cmd *cobra.Command, _ []string) error { return executeAgentHook(cmd, agentName, hookName, false) }, diff --git a/cmd/entire/cli/hook_registry_test.go b/cmd/entire/cli/hook_registry_test.go index 90ef4eabb1..0763bd5c0f 100644 --- a/cmd/entire/cli/hook_registry_test.go +++ b/cmd/entire/cli/hook_registry_test.go @@ -371,14 +371,26 @@ func TestAgentHooksCmd_AttachesHookSessionContext(t *testing.T) { } require.NotNil(t, agentCmd, "expected to find %s subcommand under hooks", agentSubcommand) - require.NotNil(t, agentCmd.PersistentPreRun, - "PersistentPreRun must attach the hook session context") - require.Nil(t, agentCmd.PersistentPreRunE, - "PersistentPreRunE must stay unset: cobra would run it instead of PersistentPreRun") - require.Nil(t, agentCmd.PersistentPostRun, - "PersistentPostRun must stay unset: main.go flushes the log sink") - require.Nil(t, agentCmd.PersistentPostRunE, - "PersistentPostRunE must stay unset: main.go flushes the log sink") + // The shared agent command must stay clear. cobra.EnableTraverseRunHooks + // runs every ancestor's PersistentPreRun, so anything attached to this + // command that is not a lifecycle verb — Antigravity's title-tee, which + // agy fires on every state change — would inherit the session scan and + // redactor construction with no way to opt out. + require.Nil(t, agentCmd.PersistentPreRun, + "the shared agent command must not define a PersistentPreRun; it is set per verb") + + verbs := agentCmd.Commands() + require.NotEmpty(t, verbs, "expected hook verbs under %s", agentSubcommand) + for _, verb := range verbs { + require.NotNil(t, verb.PersistentPreRun, + "%s: PersistentPreRun must attach the hook session context", verb.Name()) + require.Nil(t, verb.PersistentPreRunE, + "%s: PersistentPreRunE must stay unset: cobra would run it instead of PersistentPreRun", verb.Name()) + require.Nil(t, verb.PersistentPostRun, + "%s: PersistentPostRun must stay unset: main.go flushes the log sink", verb.Name()) + require.Nil(t, verb.PersistentPostRunE, + "%s: PersistentPostRunE must stay unset: main.go flushes the log sink", verb.Name()) + } }) } } diff --git a/cmd/entire/cli/hooks_antigravity_title.go b/cmd/entire/cli/hooks_antigravity_title.go new file mode 100644 index 0000000000..95b40d6b48 --- /dev/null +++ b/cmd/entire/cli/hooks_antigravity_title.go @@ -0,0 +1,79 @@ +package cli + +import ( + "bytes" + "encoding/base64" + "io" + + "github.com/entireio/cli/cmd/entire/cli/agent/antigravity" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/spf13/cobra" +) + +// newAntigravityTitleTeeCmd implements `entire hooks antigravity title-tee`. +// +// Antigravity invokes the configured title command on every agent state +// change, piping a state JSON (the only agy surface exposing token usage — +// same payload as the statusline script) to stdin. This command tees that +// JSON into the snapshot store and, with --wrap, pipes it through to the +// user's original title command so their window title is preserved. +// +// Contract: NEVER exit non-zero and NEVER write noise to stdout — stdout is +// rendered verbatim as the terminal window title. It also must work outside +// git repos and without entire being enabled (the title config is global). +func newAntigravityTitleTeeCmd() *cobra.Command { + var wrap, wrapB64 string + cmd := &cobra.Command{ + Use: "title-tee", + Short: "Tee agy state JSON (title/statusline payload) into the token snapshot store", + Hidden: true, + // NoArgs is safe: agy invokes the title command with stdin only, never positional args. + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + payload, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return nil //nolint:nilerr // never break agy's title rendering + } + if err := antigravity.AppendStatusSnapshot(payload); err != nil { + // Best-effort capture: a persistent I/O failure (disk full, + // unwritable cache dir) leaves a breadcrumb without ever + // affecting stdout or the exit code. + logging.Debug(cmd.Context(), "antigravity title-tee: snapshot append failed", "error", err.Error()) + } + + original := wrap + if wrapB64 != "" { + decoded, decodeErr := base64.RawURLEncoding.DecodeString(wrapB64) + if decodeErr != nil { + logging.Debug(cmd.Context(), "antigravity title-tee: --wrap-b64 is not base64url; original title command not run", "error", decodeErr.Error()) + return nil + } + original = string(decoded) + } + if original == "" { + return nil + } + // original is the user's own title command, preserved from agy's + // settings.json by InstallTitleTee (quoted for sh, or base64url + // for cmd.exe). Running it through the host's shell is + // intentional — it is exactly what agy did with that string before + // the tee took the slot — not an external-input injection surface. + wrapped := wrappedTitleCommand(cmd.Context(), original) + wrapped.Stdin = bytes.NewReader(payload) + wrapped.Stdout = cmd.OutOrStdout() + wrapped.Stderr = cmd.ErrOrStderr() + if err := wrapped.Run(); err != nil { + // A failing user title script must not fail the tee (token + // capture already succeeded), but leave a breadcrumb — the + // user's own title rendering silently disappearing is + // otherwise undiagnosable. + logging.Debug(cmd.Context(), "antigravity title-tee: wrapped title command failed", "error", err.Error()) + } + return nil + }, + } + cmd.Flags().StringVar(&wrap, "wrap", "", "original title command to chain after capturing") + cmd.Flags().StringVar(&wrapB64, "wrap-b64", "", "original title command, base64url-encoded, to chain after capturing (written on Windows hosts)") + cmd.MarkFlagsMutuallyExclusive("wrap", "wrap-b64") + return cmd +} diff --git a/cmd/entire/cli/hooks_antigravity_title_other.go b/cmd/entire/cli/hooks_antigravity_title_other.go new file mode 100644 index 0000000000..80d5a89a42 --- /dev/null +++ b/cmd/entire/cli/hooks_antigravity_title_other.go @@ -0,0 +1,15 @@ +//go:build !windows + +package cli + +import ( + "context" + "os/exec" +) + +// wrappedTitleCommand runs a preserved agy title command the way agy itself +// runs the slot on this host: through sh. See newAntigravityTitleTeeCmd for +// why executing the user's own string here is intentional. +func wrappedTitleCommand(ctx context.Context, original string) *exec.Cmd { + return exec.CommandContext(ctx, "sh", "-c", original) +} diff --git a/cmd/entire/cli/hooks_antigravity_title_test.go b/cmd/entire/cli/hooks_antigravity_title_test.go new file mode 100644 index 0000000000..25b180993b --- /dev/null +++ b/cmd/entire/cli/hooks_antigravity_title_test.go @@ -0,0 +1,164 @@ +package cli + +import ( + "bytes" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" +) + +// Note: these tests use t.Setenv, so t.Parallel() is not called. + +func TestTitleTee_WritesSnapshotAndStaysSilent(t *testing.T) { + dir := t.TempDir() + t.Setenv("ENTIRE_ANTIGRAVITY_STATUS_DIR", dir) + + payload := `{"conversation_id":"conv-9","agent_state":"working","context_window":{"total_input_tokens":500,"total_output_tokens":25,"context_window_size":100000,"current_usage":{"input_tokens":400,"output_tokens":25,"cache_creation_input_tokens":50,"cache_read_input_tokens":300}}}` + + cmd := newAntigravityTitleTeeCmd() + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader(payload)) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + // stdout must be empty — agy renders it verbatim as the window title + if out.Len() != 0 { + t.Errorf("stdout not empty: %q", out.String()) + } + + // snapshot file must exist + snapFile := filepath.Join(dir, "conv-9.jsonl") + if _, err := os.Stat(snapFile); err != nil { + t.Errorf("snapshot file not created: %v", err) + } +} + +func TestTitleTee_WrapStillCapturesSnapshot(t *testing.T) { + dir := t.TempDir() + t.Setenv("ENTIRE_ANTIGRAVITY_STATUS_DIR", dir) + + payload := `{"conversation_id":"conv-11","agent_state":"working","context_window":{"total_input_tokens":700,"total_output_tokens":40}}` + + cmd := newAntigravityTitleTeeCmd() + cmd.SetArgs([]string{"--wrap", "cat"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader(payload)) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + + // --wrap cat must pipe the payload through verbatim... + if got, want := strings.TrimSpace(out.String()), strings.TrimSpace(payload); got != want { + t.Errorf("stdout = %q, want %q", got, want) + } + + // ...AND the snapshot must still be captured. + snapFile := filepath.Join(dir, "conv-11.jsonl") + if _, err := os.Stat(snapFile); err != nil { + t.Errorf("snapshot file not created under --wrap: %v", err) + } +} + +// The base64url form is what InstallTitleTee writes on Windows hosts; the +// tee must decode it and pipe the payload through exactly like --wrap. +func TestTitleTee_WrapBase64StillCapturesSnapshot(t *testing.T) { + dir := t.TempDir() + t.Setenv("ENTIRE_ANTIGRAVITY_STATUS_DIR", dir) + + payload := `{"conversation_id":"conv-b64","context_window":{"total_input_tokens":1,"total_output_tokens":1,"context_window_size":10}}` + + cmd := newAntigravityTitleTeeCmd() + cmd.SetArgs([]string{"--wrap-b64", base64.RawURLEncoding.EncodeToString([]byte("cat"))}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader(payload)) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if out.String() != payload { + t.Errorf("wrapped cat did not receive the payload verbatim: got %q", out.String()) + } + if _, err := os.Stat(filepath.Join(dir, "conv-b64.jsonl")); err != nil { + t.Errorf("snapshot file not created under --wrap-b64: %v", err) + } + + // A token that is not base64url is a config error, not a title error: + // the snapshot is still captured and nothing reaches stdout. + cmd = newAntigravityTitleTeeCmd() + cmd.SetArgs([]string{"--wrap-b64", "not*base64!"}) + out.Reset() + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader(payload)) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute with a bad token: %v", err) + } + if out.Len() != 0 { + t.Errorf("stdout not empty for a bad token: %q", out.String()) + } +} + +func TestTitleTee_GarbageInputAndFailingWrapNeverError(t *testing.T) { + dir := t.TempDir() + t.Setenv("ENTIRE_ANTIGRAVITY_STATUS_DIR", dir) + + cmd := newAntigravityTitleTeeCmd() + cmd.SetArgs([]string{"--wrap", "false"}) + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetIn(strings.NewReader("not json")) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute returned error: %v", err) + } +} + +// agy fires its title command on every state change during a turn, so +// title-tee must inherit nothing that scans session state or builds redactors: +// one real 35-second turn logged 32 "redaction configured" lines when it did. +// +// cobra.EnableTraverseRunHooks (root.go) runs EVERY ancestor's PersistentPreRun +// from the root down, not just the nearest, so title-tee cannot shadow an +// ancestor's hook with its own — the only way it inherits nothing is for no +// ancestor to define one. The command path is load-bearing and must not move +// to dodge this: `entire hooks antigravity title-tee` is persisted verbatim in +// users' agy settings.json by InstallTitleTee and matched by shape on +// uninstall, so re-parenting would silently strand every installed tee. +func TestTitleTee_InheritsNoPersistentPreRun(t *testing.T) { + root := NewRootCmd() + + titleTee, _, err := root.Find([]string{"hooks", "antigravity", "title-tee"}) + if err != nil { + t.Fatalf("`entire hooks antigravity title-tee` must stay at this exact path: %v", err) + } + if titleTee.Name() != "title-tee" { + t.Fatalf("resolved %q, not title-tee; the persisted command path moved", titleTee.CommandPath()) + } + + // Every ancestor below the root: the root's own hook sets up logging and + // belongs on every command. The per-agent hooks command is the one that + // must stay clear — its hook scanned session state and built redactors. + for p := titleTee; p != nil && p.Parent() != nil; p = p.Parent() { + if p.PersistentPreRun != nil || p.PersistentPreRunE != nil { + t.Errorf("%q defines a PersistentPreRun, which EnableTraverseRunHooks runs for title-tee too", p.CommandPath()) + } + } + + // The lifecycle verbs still get one: that is where the hook session belongs. + stop, _, err := root.Find([]string{"hooks", "antigravity", "stop"}) + if err != nil { + t.Fatalf("find stop verb: %v", err) + } + if stop.PersistentPreRun == nil && stop.PersistentPreRunE == nil { + t.Error("the stop verb lost its hook-session PersistentPreRun") + } +} diff --git a/cmd/entire/cli/hooks_antigravity_title_windows.go b/cmd/entire/cli/hooks_antigravity_title_windows.go new file mode 100644 index 0000000000..c05cfbab95 --- /dev/null +++ b/cmd/entire/cli/hooks_antigravity_title_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package cli + +import ( + "context" + "os/exec" + "syscall" +) + +// wrappedTitleCommand runs a preserved agy title command the way agy itself +// runs the slot on this host: through cmd.exe. The whole line is handed over +// verbatim via CmdLine — Go's argv quoting would rewrite the user's own quotes +// and carets — with /d to skip AutoRun and /s so cmd strips exactly the one +// pair of outer quotes added here. Nothing else is interpolated: original is +// the string agy would have run through cmd.exe itself had the tee not taken +// the slot (see newAntigravityTitleTeeCmd). +func wrappedTitleCommand(ctx context.Context, original string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "cmd.exe") + cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: `cmd.exe /d /s /c "` + original + `"`} + return cmd +} diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index 4392dea0f5..3ecb1a7158 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -10,6 +10,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/logging" // 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" @@ -77,7 +78,27 @@ func newHooksCmd() *cobra.Command { continue } if handler, ok := agent.AsHookSupport(ag); ok { - cmd.AddCommand(newAgentHooksCmd(agentName, handler)) + sub := newAgentHooksCmd(agentName, handler) + // title-tee is not a lifecycle verb: it runs globally (outside + // git repos, without the enabled check) and owns its stdout. + // + // Exempt from the root `.entire` guard, and only this command: + // cobra.EnableTraverseRunHooks means root's PersistentPreRunE runs + // for it, and RequireEntireDir answers nil only for + // ErrNotARepository — an unresolvable repository (git's + // safe.directory refusal, git absent from PATH) or a `.entire` that + // is not a real directory makes it print a multi-line remedy to + // stderr and fail the command. agy fires this on every agent state + // change, so that would be a repeating stderr blob and a non-zero + // exit per fire, against this command's NEVER-exit-non-zero + // contract, with token capture silently dead. The tee writes only + // to the per-user cache, never under `.entire`, so it has nothing + // the guard protects. The lifecycle verbs keep the guard: they are + // the checkpoint-writing path and must fail closed. + if agentName == agent.AgentNameAntigravity { + sub.AddCommand(exemptFromEntireDirCheck(newAntigravityTitleTeeCmd())) + } + cmd.AddCommand(sub) } } diff --git a/cmd/entire/cli/hooks_git_cmd_test.go b/cmd/entire/cli/hooks_git_cmd_test.go index 8d710c4aee..6eb58079b4 100644 --- a/cmd/entire/cli/hooks_git_cmd_test.go +++ b/cmd/entire/cli/hooks_git_cmd_test.go @@ -95,7 +95,7 @@ func TestHookPreRuns_GateSkipsRepo(t *testing.T) { // SetContext, so the command's context is still the one we handed it. for name, hookCmd := range map[string]*cobra.Command{ "git hooks": newHooksGitCmd(), - "agent hooks": agentHooksCmd(t, testAgentName), + "agent hooks": agentHookVerbCmd(t, testAgentName), } { base := context.Background() hookCmd.SetContext(base) @@ -206,14 +206,20 @@ func TestHooksGitCmd_ExposesPostRewriteSubcommand(t *testing.T) { } } -// agentHooksCmd returns the hooks subcommand for one agent, whose pre-run is the -// call site that used to rely on withHookSession's own gate. -func agentHooksCmd(t *testing.T, agentName string) *cobra.Command { +// agentHookVerbCmd returns one lifecycle verb of an agent's hooks command. +// The verb, not the agent command: the hook-session pre-run lives per verb so +// that non-verb commands attached to the same agent (Antigravity's title-tee) +// do not inherit it. +func agentHookVerbCmd(t *testing.T, agentName string) *cobra.Command { t.Helper() for _, sub := range newHooksCmd().Commands() { if sub.Use == agentName { - return sub + verbs := sub.Commands() + if len(verbs) == 0 { + t.Fatalf("agent %q has no hook verbs", agentName) + } + return verbs[0] } } t.Fatalf("no hooks subcommand for %q", agentName) 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..eff6b92699 --- /dev/null +++ b/cmd/entire/cli/integration_test/antigravity_test.go @@ -0,0 +1,729 @@ +//go:build integration + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/checkpoint" + "github.com/entireio/cli/cmd/entire/cli/execx" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/entireio/cli/cmd/entire/cli/trailers" + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestAntigravity_FullEventFlow drives the three installed Antigravity hooks +// (pre-invocation, pre-tool-use, 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. (PostToolUse/PostInvocation +// are deliberately not installed — no lifecycle mapping.) +func TestAntigravity_FullEventFlow(t *testing.T) { + t.Parallel() + env := newAntigravityEnv(t) + + conversationID := "antigravity-it-conv-id" + transcriptPath := filepath.Join(antigravityBrainDir(env.RepoDir), 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 wire format (captured on 1.0.14/1.0.15, re-verified unchanged on + // 1.1.1): 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") + + 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) + } + + var rawFiles []any + if v, ok := state["files_touched"].([]any); ok { + rawFiles = v + } + 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) +} + +// TestAntigravity_MidTurnCommitWithUnwrittenTranscriptStillCondenses pins the +// dangling-trailer fix: agy writes its transcript AFTER the Stop hook, so a +// first-turn mid-turn commit condenses while the transcript file doesn't exist +// yet (PrepareTranscript materialises an empty placeholder). Condensation must +// degrade to a files/prompt-only checkpoint — NOT error — because +// prepare-commit-msg already stamped the Entire-Checkpoint trailer; an error +// here leaves the commit permanently referencing a checkpoint that was never +// written to entire/checkpoints/v1. +func TestAntigravity_MidTurnCommitWithUnwrittenTranscriptStillCondenses(t *testing.T) { + t.Parallel() + env := newAntigravityEnv(t) + + conversationID := "antigravity-it-unwritten-transcript" + // Deliberately do NOT create the transcript file: real agy has not written + // it yet when a mid-turn commit fires. + transcriptPath := filepath.Join(antigravityBrainDir(env.RepoDir), conversationID, ".system_generated", "logs", "transcript_full.jsonl") + + common := map[string]any{ + "conversationId": conversationID, + "workspacePaths": []string{env.RepoDir}, + "transcriptPath": transcriptPath, + "artifactDirectoryPath": filepath.Join(env.RepoDir, ".gemini", "antigravity-cli", "artifacts"), + } + + require.NoError(t, runAntigravityHook(t, env.RepoDir, "pre-invocation", mergeMaps(common, map[string]any{ + "invocationNum": 0, + "initialNumSteps": 1, + }))) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "pre-tool-use", mergeMaps(common, map[string]any{ + "toolCall": map[string]any{ + "name": "write_to_file", + "args": map[string]any{"TargetFile": "docs/blue.md", "Overwrite": false}, + }, + "stepIdx": 1, + }))) + + env.WriteFile("docs/blue.md", "Blue is a calm colour.\n") + gitCLICommitWithEntireHooks(t, env, "Add blue docs", "docs/blue.md") + + // The commit must carry the trailer AND the checkpoint it references must + // actually exist on entire/checkpoints/v1. + headHash := env.GetHeadHash() + repo, err := git.PlainOpen(env.RepoDir) + require.NoError(t, err) + commitObj, err := repo.CommitObject(plumbing.NewHash(headHash)) + require.NoError(t, err) + checkpointID, found := trailers.ParseCheckpoint(commitObj.Message) + require.True(t, found, "user commit should carry an Entire-Checkpoint trailer") + + metadataPath := SessionMetadataPath(checkpointID.String()) + metadataContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, metadataPath) + require.True(t, found, + "checkpoint metadata must exist at %s — an empty/unwritten transcript must degrade, not strand the trailer", metadataPath) + var metadata checkpoint.Metadata + require.NoError(t, json.Unmarshal([]byte(metadataContent), &metadata)) + require.Equal(t, conversationID, metadata.SessionID) + require.Contains(t, metadata.FilesTouched, "docs/blue.md", + "hook-captured files must survive the empty transcript") +} + +// TestAntigravity_StopAfterMidTurnCommitCheckpointsLateShellFiles pins the +// uncommitted-late-work fix: after a mid-turn commit condenses everything +// tracked, agy can still create files via run_command (shell), which the +// PreToolUse extractor cannot see. Stop must checkpoint those uncommitted +// files (SaveStep → shadow branch) instead of skipping — the committed-state +// filters drop only the already-condensed changes, not the late ones. +func TestAntigravity_StopAfterMidTurnCommitCheckpointsLateShellFiles(t *testing.T) { + t.Parallel() + env := newAntigravityEnv(t) + + conversationID := "antigravity-it-late-shell-file" + transcriptPath := filepath.Join(antigravityBrainDir(env.RepoDir), conversationID, ".system_generated", "logs", "transcript_full.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 docs/blue.md, commit it, then create docs/late.md"}`+"\n"), + 0o600)) + + common := map[string]any{ + "conversationId": conversationID, + "workspacePaths": []string{env.RepoDir}, + "transcriptPath": transcriptPath, + "artifactDirectoryPath": filepath.Join(env.RepoDir, ".gemini", "antigravity-cli", "artifacts"), + } + + require.NoError(t, runAntigravityHook(t, env.RepoDir, "pre-invocation", mergeMaps(common, map[string]any{ + "invocationNum": 0, + "initialNumSteps": 1, + }))) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "pre-tool-use", mergeMaps(common, map[string]any{ + "toolCall": map[string]any{ + "name": "write_to_file", + "args": map[string]any{"TargetFile": "docs/blue.md", "Overwrite": false}, + }, + "stepIdx": 1, + }))) + + env.WriteFile("docs/blue.md", "Blue is a calm colour.\n") + gitCLICommitWithEntireHooks(t, env, "Add blue docs", "docs/blue.md") + + // agy now creates a file via run_command (shell) — no PreToolUse fires for + // it, so it never enters FilesTouched. It stays uncommitted at Stop. + env.WriteFile("docs/late.md", "Written after the mid-turn commit.\n") + + require.NoError(t, runAntigravityHook(t, env.RepoDir, "stop", mergeMaps(common, map[string]any{ + "executionNum": 1, + "terminationReason": "model_stop", + "error": "", + "fullyIdle": true, + }))) + + // The uncommitted late file must have produced a shadow-branch checkpoint. + var shadowBranches []string + for _, branch := range env.ListBranchesWithPrefix("entire/") { + if branch == paths.MetadataBranchName || branch == paths.TrailsBranchName { + continue + } + shadowBranches = append(shadowBranches, branch) + } + require.NotEmpty(t, shadowBranches, + "Stop after a mid-turn commit must checkpoint uncommitted late files (docs/late.md), not skip SaveStep") +} + +func TestAntigravity_StopAfterAgentCommitDoesNotCreateNewShadowBranch(t *testing.T) { + t.Parallel() + env := newAntigravityEnv(t) + + conversationID := "antigravity-it-agent-commit" + transcriptPath := filepath.Join(antigravityBrainDir(env.RepoDir), conversationID, ".system_generated", "logs", "transcript_full.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 docs/blue.md and commit it"}`+"\n"), + 0o600)) + + common := map[string]any{ + "conversationId": conversationID, + "workspacePaths": []string{env.RepoDir}, + "transcriptPath": transcriptPath, + "artifactDirectoryPath": filepath.Join(env.RepoDir, ".gemini", "antigravity-cli", "artifacts"), + } + + preInv := mergeMaps(common, map[string]any{ + "invocationNum": 0, + "initialNumSteps": 1, + }) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "pre-invocation", preInv)) + + preTU := mergeMaps(common, map[string]any{ + "toolCall": map[string]any{ + "name": "write_to_file", + "args": map[string]any{ + "TargetFile": "docs/blue.md", + "Overwrite": false, + }, + }, + "stepIdx": 1, + }) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "pre-tool-use", preTU)) + + env.WriteFile("docs/blue.md", "Blue is a calm colour.\n") + gitCLICommitWithEntireHooks(t, env, "Add blue docs", "docs/blue.md") + + require.NoError(t, runAntigravityHook(t, env.RepoDir, "stop", mergeMaps(common, map[string]any{ + "executionNum": 1, + "terminationReason": "model_stop", + "error": "", + "fullyIdle": true, + }))) + + for _, branch := range env.ListBranchesWithPrefix("entire/") { + if branch == paths.MetadataBranchName || branch == paths.TrailsBranchName { + continue + } + t.Fatalf("unexpected shadow branch after agent commit and stop: %s", branch) + } +} + +func gitCLICommitWithEntireHooks(t *testing.T, env *TestEnv, message string, files ...string) { + t.Helper() + for _, file := range files { + cmd := exec.CommandContext(t.Context(), "git", "add", "--", file) + cmd.Dir = env.RepoDir + cmd.Env = env.gitHookEnv() + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add %s failed: %v\nOutput: %s", file, err, output) + } + } + + msgFile := filepath.Join(env.RepoDir, ".git", "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(msgFile, []byte(message), 0o600)) + if output, err := env.prepareCommitMsgCmd(false, msgFile, "message").CombinedOutput(); err != nil { + t.Fatalf("prepare-commit-msg failed: %v\nOutput: %s", err, output) + } + + commitCmd := exec.CommandContext(t.Context(), "git", "commit", "-F", msgFile) + commitCmd.Dir = env.RepoDir + commitCmd.Env = env.gitHookEnv() + if output, err := commitCmd.CombinedOutput(); err != nil { + t.Fatalf("git commit failed: %v\nOutput: %s", err, output) + } + + postCmd := exec.CommandContext(t.Context(), getTestBinary(), "hooks", "git", "post-commit") + postCmd.Dir = env.RepoDir + postCmd.Env = env.gitHookEnv() + if output, err := postCmd.CombinedOutput(); err != nil { + t.Fatalf("post-commit failed: %v\nOutput: %s", err, output) + } +} + +// TestAntigravity_TokenUsageInCheckpointMetadata proves the entire.io UI +// contract end-to-end: agy's out-of-band token counts (the title-script +// context_window, captured into the snapshot store) flow through TurnStart +// baseline → TurnEnd delta → SaveStep → SessionState → condensation → the +// committed checkpoint metadata on entire/checkpoints/v1. +// +// The snapshot totals are cumulative per conversation, so the per-turn delta is +// (latest − baseline). Baseline here is 1000/100 (input/output) captured at +// TurnStart; the latest before Stop is 4500/400 → expected delta 3500/300. +func TestAntigravity_TokenUsageInCheckpointMetadata(t *testing.T) { + t.Parallel() + env := newAntigravityEnv(t) + + statusDir := t.TempDir() + configDir := t.TempDir() + conversationID := "antigravity-tokens-it" + extraEnv := []string{ + "ENTIRE_ANTIGRAVITY_STATUS_DIR=" + statusDir, + "ENTIRE_ANTIGRAVITY_CONFIG_DIR=" + configDir, + } + + snapshotPath := filepath.Join(statusDir, conversationID+".jsonl") + appendSnapshotLine := func(line string) { + f, err := os.OpenFile(snapshotPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + require.NoError(t, err) + _, werr := f.WriteString(line + "\n") + require.NoError(t, f.Close()) + require.NoError(t, werr) + } + + transcriptPath := filepath.Join(antigravityBrainDir(env.RepoDir), 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 tok.txt"}`+"\n"), + 0o600)) + + common := map[string]any{ + "conversationId": conversationID, + "workspacePaths": []string{env.RepoDir}, + "transcriptPath": transcriptPath, + "artifactDirectoryPath": filepath.Join(env.RepoDir, ".gemini", "antigravity-cli", "artifacts"), + } + + // 1. Seed the BASELINE snapshot before TurnStart captures it. + appendSnapshotLine(`{"ts":"2026-06-03T10:00:00Z","conversation_id":"` + conversationID + + `","context_window":{"total_input_tokens":1000,"total_output_tokens":100,` + + `"current_usage":{"cache_read_input_tokens":700}}}`) + + // 2. TurnStart (invocationNum 0) → baseline captured = 1000/100. + preInv := mergeMaps(common, map[string]any{ + "invocationNum": 0, + "initialNumSteps": 1, + }) + require.NoError(t, runAntigravityHookWithEnv(t, env.RepoDir, "pre-invocation", preInv, extraEnv), + "pre-invocation should capture the token baseline") + + // 3. PreToolUse writing a real file, so the turn has a working-tree change + // to checkpoint and condense. + env.WriteFile("tok.txt", "token test contents\n") + preTU := mergeMaps(common, map[string]any{ + "toolCall": map[string]any{ + "name": "write_to_file", + "args": map[string]any{"TargetFile": "tok.txt", "Overwrite": false}, + }, + "stepIdx": 1, + }) + require.NoError(t, runAntigravityHookWithEnv(t, env.RepoDir, "pre-tool-use", preTU, extraEnv), + "pre-tool-use should record tok.txt in files_touched") + + // 4. Append the SECOND (latest) snapshot: cumulative totals grew. + appendSnapshotLine(`{"ts":"2026-06-03T10:05:00Z","conversation_id":"` + conversationID + + `","context_window":{"total_input_tokens":4500,"total_output_tokens":400,` + + `"current_usage":{"cache_read_input_tokens":1500,"cache_creation_input_tokens":50}}}`) + + // 5. Stop (fullyIdle true) → TurnEnd → OOB delta 3500/300 → SaveStep. + stopIdle := mergeMaps(common, map[string]any{ + "executionNum": 1, + "terminationReason": "model_stop", + "error": "", + "fullyIdle": true, + }) + require.NoError(t, runAntigravityHookWithEnv(t, env.RepoDir, "stop", stopIdle, extraEnv), + "stop hook should finalize the turn and accumulate token usage") + + // Sanity: SessionState shows the accumulated delta before commit. This + // isolates the OOB capture+delta+SaveStep path from condensation. + statePath := filepath.Join(env.RepoDir, ".git", "entire-sessions", conversationID+".json") + stateBytes, err := os.ReadFile(statePath) + require.NoError(t, err) + var state struct { + TokenUsage *struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + } `json:"token_usage"` + } + require.NoError(t, json.Unmarshal(stateBytes, &state)) + require.NotNil(t, state.TokenUsage, "session state should record token usage after stop") + assert.Equal(t, 3500, state.TokenUsage.InputTokens, "session state input tokens") + assert.Equal(t, 300, state.TokenUsage.OutputTokens, "session state output tokens") + + // 6. Commit: prepare-commit-msg adds the Entire-Checkpoint trailer, then + // post-commit condenses the session (with its token usage) onto + // entire/checkpoints/v1. + env.GitCommitWithShadowHooks("Add tok.txt", "tok.txt") + + // 7. Read the committed checkpoint metadata and assert the token usage + // reached both the per-session metadata.json and the summary aggregate. + checkpointID := env.GetLatestCheckpointID() + + sessionMeta, found := env.ReadFileFromBranch(paths.MetadataBranchName, SessionMetadataPath(checkpointID)) + require.True(t, found, "per-session metadata.json should exist on the metadata branch") + var sessionMetadata checkpoint.Metadata + require.NoError(t, json.Unmarshal([]byte(sessionMeta), &sessionMetadata)) + require.NotNil(t, sessionMetadata.TokenUsage, "committed per-session metadata should carry token_usage") + assert.Equal(t, 3500, sessionMetadata.TokenUsage.InputTokens, "committed per-session input tokens") + assert.Equal(t, 300, sessionMetadata.TokenUsage.OutputTokens, "committed per-session output tokens") + + summaryRaw, found := env.ReadFileFromBranch(paths.MetadataBranchName, CheckpointSummaryPath(checkpointID)) + require.True(t, found, "CheckpointSummary metadata.json should exist on the metadata branch") + var summary checkpoint.CheckpointSummary + require.NoError(t, json.Unmarshal([]byte(summaryRaw), &summary)) + require.NotNil(t, summary.TokenUsage, "CheckpointSummary should carry aggregated token_usage") + assert.Equal(t, 3500, summary.TokenUsage.InputTokens, "summary aggregate input tokens") + assert.Equal(t, 300, summary.TokenUsage.OutputTokens, "summary aggregate output tokens") +} + +// TestAntigravity_PromptInCheckpointMetadata proves the condensation-time +// late-flush prompt fallback end-to-end for Antigravity (agy). +// +// Background: agy writes its JSONL transcript AFTER the Stop hook fires, so the +// TurnEnd prompt backfill (lifecycle.go) reads an EMPTY transcript and prompt.txt +// stays empty. The committed "prompt" field would therefore be empty. The +// condensation-time fallback (resolvePromptsFromLateFlushedTranscript) re-extracts +// the prompt from the live transcript at `git commit` time — by then agy has +// finished its asynchronous write, so the transcript is populated. +// +// The test reproduces that exact timing: +// 1. TurnStart (PreInvocation, invocationNum 0) — transcript file is EMPTY. +// 2. PreToolUse write_to_file touches foo.txt; the file is written to the worktree. +// 3. Stop (fullyIdle true) — SaveStep creates the shadow checkpoint while the +// transcript is STILL EMPTY (proving the backfill cannot recover the prompt). +// 4. The transcript is populated with a USER_INPUT/ step BEFORE the +// git commit (simulating agy finishing its late write). +// 5. git commit → PostCommit condensation → the committed session metadata's +// prompt is recovered from the now-populated transcript. +func TestAntigravity_PromptInCheckpointMetadata(t *testing.T) { + t.Parallel() + env := newAntigravityEnv(t) + env.InitEntire() + + const requestText = "add a foo.txt file with the word bar in it" + + conversationID := "antigravity-it-prompt-conv-id" + transcriptPath := filepath.Join(antigravityBrainDir(env.RepoDir), conversationID, ".system_generated", "logs", "transcript.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o750)) + + // CRUX: transcript is EMPTY at TurnStart and TurnEnd. agy writes it after Stop. + require.NoError(t, os.WriteFile(transcriptPath, []byte{}, 0o600), + "transcript must start empty to simulate agy's late flush") + + common := map[string]any{ + "conversationId": conversationID, + "workspacePaths": []string{env.RepoDir}, + "transcriptPath": transcriptPath, + "artifactDirectoryPath": filepath.Join(env.RepoDir, ".gemini", "antigravity-cli", "artifacts"), + } + + // TurnStart: first model invocation of the conversation (invocationNum=0). + 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") + + // PreToolUse write_to_file: records foo.txt in state.FilesTouched. + 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") + + // The agent actually writes the file to the worktree so there is a diff to + // checkpoint and later commit. + env.WriteFile("foo.txt", "bar\n") + + // Stop (fullyIdle=true): TurnEnd → SaveStep creates the shadow checkpoint. + // The transcript is STILL EMPTY here, so the TurnEnd prompt backfill recovers + // nothing and prompt.txt is empty. This is what makes the test exercise the + // condensation-time fallback rather than the backfill path. + 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 and SaveStep the checkpoint") + + // LATE FLUSH: agy finishes writing the transcript AFTER Stop, BEFORE the commit. + // Now the live transcript carries the USER_INPUT step with the . + step := map[string]any{ + "step_index": 0, + "source": "USER_EXPLICIT", + "type": "USER_INPUT", + "status": "DONE", + "content": "\n" + requestText + "\n", + } + stepJSON, err := json.Marshal(step) + require.NoError(t, err) + require.NoError(t, os.WriteFile(transcriptPath, append(stepJSON, '\n'), 0o600), + "populate the transcript before commit to simulate agy's completed late write") + + // Commit → PostCommit condensation. The fallback re-extracts the prompt from + // the now-populated live transcript. + env.GitCommitWithShadowHooks("Add foo.txt", "foo.txt") + + // Resolve the checkpoint ID from the user commit's Entire-Checkpoint trailer. + headHash := env.GetHeadHash() + repo, err := git.PlainOpen(env.RepoDir) + require.NoError(t, err) + commitObj, err := repo.CommitObject(plumbing.NewHash(headHash)) + require.NoError(t, err) + checkpointID, found := trailers.ParseCheckpoint(commitObj.Message) + require.True(t, found, "user commit should carry an Entire-Checkpoint trailer") + + // Sanity-check that the checkpoint's session metadata.json was written (the + // checkpoint exists and is sharded under the checkpoint ID). + metadataPath := SessionMetadataPath(checkpointID.String()) + metadataContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, metadataPath) + require.True(t, found, "session metadata.json should exist at %s", metadataPath) + var metadata checkpoint.Metadata + require.NoError(t, json.Unmarshal([]byte(metadataContent), &metadata), + "session metadata.json should parse") + require.Equal(t, conversationID, metadata.SessionID, + "checkpoint should be linked to the antigravity conversation/session") + + // PRIMARY ASSERTION: the committed session-level "prompt" (prompt.txt on + // entire/checkpoints/v1) was recovered by the condensation-time late-flush + // fallback. The transcript was empty at TurnEnd (so the backfill recovered + // nothing) and only populated before commit, so a non-empty prompt here can + // ONLY have come from resolvePromptsFromLateFlushedTranscript at condensation. + promptPath := SessionFilePath(checkpointID.String(), paths.PromptFileName) + promptContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, promptPath) + require.True(t, found, "prompt.txt should exist at %s", promptPath) + require.NotEmpty(t, strings.TrimSpace(promptContent), + "committed prompt.txt must be non-empty — the condensation-time late-flush "+ + "fallback should have recovered it from the populated transcript") + assert.Equal(t, requestText, strings.TrimSpace(promptContent), + "committed prompt should equal the text from the late-flushed transcript") +} + +// antigravityBrainDir is where these tests place agy's per-user brain +// directory: inside the test repo, so nothing touches the developer's real +// ~/.gemini. Every transcript path a test hands to a hook lives under it, and +// every subprocess that resolves the agent's session store is told about it +// (ENTIRE_TEST_ANTIGRAVITY_BRAIN_DIR), because PrepareTranscript refuses to +// materialise a placeholder outside that store. +func antigravityBrainDir(repoDir string) string { + return filepath.Join(repoDir, ".gemini", "antigravity-cli", "brain") +} + +// antigravityBrainDirEnv is the env entry that points a spawned `entire` at +// antigravityBrainDir(repoDir). +func antigravityBrainDirEnv(repoDir string) string { + return "ENTIRE_TEST_ANTIGRAVITY_BRAIN_DIR=" + antigravityBrainDir(repoDir) +} + +// newAntigravityEnv is NewFeatureBranchEnv with the brain-directory override on +// ExtraEnv, so the git hooks a commit spawns (prepare-commit-msg, post-commit) +// resolve the same session store as the antigravity hooks do. +func newAntigravityEnv(t *testing.T) *TestEnv { + t.Helper() + env := NewFeatureBranchEnv(t) + env.ExtraEnv = append(env.ExtraEnv, antigravityBrainDirEnv(env.RepoDir)) + return env +} + +func runAntigravityHook(t *testing.T, repoDir, hookName string, input map[string]any) error { + t.Helper() + return runAntigravityHookWithEnv(t, repoDir, hookName, input, nil) +} + +// runAntigravityHookWithEnv runs an Antigravity hook subprocess with extraEnv +// appended to the isolated git env. The override (e.g. +// ENTIRE_ANTIGRAVITY_STATUS_DIR) must be on the child's env to reach the +// lifecycle code that reads token snapshots, since hooks run as a subprocess +// of the real entire binary. +func runAntigravityHookWithEnv(t *testing.T, repoDir, hookName string, input map[string]any, extraEnv []string) 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 = append(testutil.GitIsolatedEnv(), antigravityBrainDirEnv(repoDir)) + cmd.Env = append(cmd.Env, extraEnv...) + 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 +} + +// TestAntigravity_PromptInCheckpointMetadata_LaterTurnManualCommit covers the +// case the first-turn test cannot: a LATER turn of the same conversation, +// where CheckpointTranscriptStart is past the first turn's lines, committed by +// the user rather than by agy. It follows agy's real order — the USER_INPUT +// step is already in the transcript when PreInvocation fires, the model's step +// lands only after Stop — and asserts the second checkpoint records the second +// prompt and not the first (trail 444: three user-reported checkpoints with a +// full transcript and an empty prompt, all on later turns). +func TestAntigravity_PromptInCheckpointMetadata_LaterTurnManualCommit(t *testing.T) { + t.Parallel() + env := newAntigravityEnv(t) + env.InitEntire() + + conversationID := "antigravity-it-prompt-later-turn" + transcriptPath := filepath.Join(antigravityBrainDir(env.RepoDir), conversationID, ".system_generated", "logs", "transcript_full.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o750)) + common := map[string]any{ + "conversationId": conversationID, + "workspacePaths": []string{env.RepoDir}, + "transcriptPath": transcriptPath, + "artifactDirectoryPath": filepath.Join(env.RepoDir, ".gemini", "antigravity-cli", "artifacts"), + } + userStep := func(text string) string { + step := map[string]any{"source": "USER_EXPLICIT", "type": "USER_INPUT", "status": "DONE", + "content": "\n" + text + "\n"} + raw, err := json.Marshal(step) + require.NoError(t, err) + return string(raw) + "\n" + } + modelStep := `{"type":"PLANNER_RESPONSE","status":"DONE"}` + "\n" + appendTranscript := func(chunk string) { + f, err := os.OpenFile(transcriptPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + require.NoError(t, err) + _, err = f.WriteString(chunk) + require.NoError(t, err) + require.NoError(t, f.Close()) + } + runTurn := func(request, file string, step int) { + // agy writes the USER_INPUT step BEFORE it fires PreInvocation. + appendTranscript(userStep(request)) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "pre-invocation", mergeMaps(common, map[string]any{ + "invocationNum": 0, "initialNumSteps": step, + }))) + require.NoError(t, runAntigravityHook(t, env.RepoDir, "pre-tool-use", mergeMaps(common, map[string]any{ + "toolCall": map[string]any{"name": "write_to_file", "args": map[string]any{"TargetFile": file, "Overwrite": false}}, + "stepIdx": step, + }))) + env.WriteFile(file, request+"\n") + require.NoError(t, runAntigravityHook(t, env.RepoDir, "stop", mergeMaps(common, map[string]any{ + "executionNum": 1, "terminationReason": "model_stop", "error": "", "fullyIdle": true, + }))) + // The model's step is flushed after Stop, before the user commits. + appendTranscript(modelStep) + } + promptOf := func(commitMsg string) string { + headHash := env.GetHeadHash() + repo, err := git.PlainOpen(env.RepoDir) + require.NoError(t, err) + commitObj, err := repo.CommitObject(plumbing.NewHash(headHash)) + require.NoError(t, err) + checkpointID, found := trailers.ParseCheckpoint(commitObj.Message) + require.True(t, found, "%s should carry an Entire-Checkpoint trailer", commitMsg) + promptContent, found := env.ReadFileFromBranch(paths.MetadataBranchName, SessionFilePath(checkpointID.String(), paths.PromptFileName)) + require.True(t, found, "prompt.txt should exist for %s", commitMsg) + return strings.TrimSpace(promptContent) + } + + runTurn("make a red note", "red.md", 1) + env.GitCommitWithShadowHooks("Add red note", "red.md") + require.Equal(t, "make a red note", promptOf("first manual commit")) + + runTurn("add another", "blue.md", 3) + env.GitCommitWithShadowHooks("Add blue note", "blue.md") + require.Equal(t, "add another", promptOf("second manual commit"), + "a later turn's checkpoint must carry that turn's prompt, not the first turn's or none") +} diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 9832e2c18c..27b5e425fc 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -9,6 +9,7 @@ package cli import ( "context" + "encoding/json" "errors" "fmt" "io/fs" @@ -97,6 +98,20 @@ func DispatchLifecycleEvent(ctx context.Context, ag agent.Agent, event *agent.Ev } } + // Conditional TurnStart (e.g. Antigravity's per-invocation PreInvocation): + // drop it when a turn is already active so a mid-turn follow-up model call + // doesn't clobber the pre-prompt baseline. A resumed turn (session idle, + // ended, condensed, or absent) falls through and is tracked. + if event.Type == agent.TurnStart && event.SuppressIfSessionActive { + state, _ := strategy.LoadSessionState(ctx, event.SessionID) //nolint:errcheck // a load failure means treat as no active session and let TurnStart proceed + if shouldSuppressConditionalTurnStart(event, state) { + logging.Info(logging.WithAgent(logging.WithComponent(ctx, "lifecycle"), ag.Name()), + "dropping conditional TurnStart for active session (follow-up invocation)", + slog.String("session_id", event.SessionID)) + return nil + } + } + switch event.Type { case agent.SessionStart: return handleLifecycleSessionStart(ctx, ag, event) @@ -869,7 +884,7 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev } } else { // Fall back to basic extraction (main transcript only) - if files, _, fileErr := analyzer.ExtractModifiedFilesFromOffset(transcriptRef, transcriptOffset); fileErr != nil { + if files, _, fileErr := analyzer.ExtractModifiedFilesFromOffset(logCtx, transcriptRef, transcriptOffset); fileErr != nil { logging.Warn(logCtx, "failed to extract modified files", slog.String("error", fileErr.Error())) } else { @@ -885,7 +900,8 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev // Single load serves both prompt retrieval and backfill. _, commitMsgSpan := perf.Start(ctx, "generate_commit_message") lastPrompt := "" - if sessionState, stateErr := strategy.LoadSessionState(ctx, sessionID); stateErr == nil && sessionState != nil { + sessionState, stateErr := strategy.LoadSessionState(ctx, sessionID) + if stateErr == nil && sessionState != nil { lastPrompt = sessionState.LastPrompt } // Backfill LastPrompt so `entire status` shows the prompt even when no @@ -958,10 +974,19 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev relModifiedFiles = mergeUnique(relModifiedFiles, FilterAndNormalizePaths(changes.Modified, repoRoot)) } - // Filter transcript-extracted files to exclude files already committed to HEAD. - // When an agent commits files mid-turn, those files are condensed by PostCommit - // and should not be re-added to FilesTouched by SaveStep. A file is "committed" - // if it exists in HEAD with the same content as the working tree. + // Filter detected changes to exclude state already committed to HEAD. + // When an agent commits files mid-turn, those changes are condensed by + // PostCommit and must not be re-checkpointed by SaveStep — otherwise a + // Stop right after the commit produces an empty duplicate checkpoint + // (observed with Antigravity, whose Stop fires after its own git commit). + // A file is "committed" if it exists in HEAD with the same content as the + // working tree. Only relModifiedFiles needs this: it merges + // transcript-extracted files, which can include already-committed ones. + // relNewFiles (untracked ⇒ never in HEAD) and relDeletedFiles (git status + // cannot report a committed deletion) are uncommitted by construction — + // filtering them against HEAD would wrongly drop deletions of files + // created-then-deleted within the session (absent from HEAD) and make + // checkpoint rewind resurrect them. relModifiedFiles = filterToUncommittedFiles(ctx, relModifiedFiles, repoRoot) normalizeSpan.End() @@ -983,6 +1008,32 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev if totalChanges == 0 { logging.Info(logCtx, "no files modified during session, skipping checkpoint") recordCaptureDegraded(ctx, sessionID, captureDegraded) + // SaveStep is skipped, but out-of-band token usage must still be + // recorded: an Antigravity turn that commits ALL its work mid-turn + // (its normal flow) ends with a clean tree, and the mid-turn + // condensation ran with a zero delta (the baseline only re-snapshots + // at TurnStart). Without this, CleanupPrePromptState deletes the + // baseline and the turn's tokens are lost permanently. + if oobUsage := computeOutOfBandTokenUsage(ctx, ag, sessionID, preState); oobUsage != nil { + if accErr := strategy.AccumulateSessionTokenUsage(ctx, sessionID, oobUsage); accErr != nil { + // This is the only path that records a checkpoint-less turn's + // tokens, so a swallowed failure here is a permanent loss. Name + // the two causes apart: a session whose state was removed + // between the turn-end transition and this accumulate (nothing + // left to attribute to) versus an I/O or lock failure on state + // that still exists. + if errors.Is(accErr, strategy.ErrStateNotFound) { + logging.Warn(logCtx, "session state already removed; out-of-band token usage for checkpoint-less turn not recorded", + slog.String("session_id", sessionID), + slog.Int("input_tokens", oobUsage.InputTokens), + slog.Int("output_tokens", oobUsage.OutputTokens)) + } else { + logging.Warn(logCtx, "failed to record out-of-band token usage for checkpoint-less turn", + slog.String("session_id", sessionID), + slog.String("error", accErr.Error())) + } + } + } transitionSessionTurnEnd(ctx, sessionID, event) if cleanupErr := CleanupPrePromptState(ctx, sessionID); cleanupErr != nil { logging.Warn(logCtx, "failed to cleanup pre-prompt state", @@ -1044,6 +1095,14 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev } } + // Out-of-band fallback: Antigravity exposes token usage only via its + // title/statusline pipe (captured by the title-tee shim), never in the + // transcript. Delta = current cumulative totals minus the TurnStart + // baseline stored in PrePromptState. + if tokenUsage == nil { + tokenUsage = computeOutOfBandTokenUsage(ctx, ag, sessionID, preState) + } + // Build fully-populated step context and delegate to strategy stepCtx := strategy.StepContext{ SessionID: sessionID, @@ -1791,7 +1850,7 @@ func subagentTranscriptAndFiles( if transcriptPath != "" { transcriptToScan = transcriptPath } - files, _, err := analyzer.ExtractModifiedFilesFromOffset(transcriptToScan, 0) + files, _, err := analyzer.ExtractModifiedFilesFromOffset(logCtx, transcriptToScan, 0) if err != nil && opts.analyzerFilesOnly { // With no worktree-diff backup, leave the live record for SessionEnd to // retry rather than permanently completing it as read-only. @@ -2308,6 +2367,61 @@ func markSessionEnded(ctx context.Context, event *agent.Event, sessionID string, return ended, nil } +// computeOutOfBandTokenUsage returns the turn's token delta for +// OutOfBandTokenSource agents (e.g. Antigravity, whose only token surface is +// the title/statusline pipe captured by the title-tee shim): current +// cumulative totals minus the TurnStart baseline stored in PrePromptState. +// Returns nil for other agents, on error (logged), or when no data exists. +func computeOutOfBandTokenUsage(ctx context.Context, ag agent.Agent, sessionID string, preState *PrePromptState) *agent.TokenUsage { + src, ok := agent.AsOutOfBandTokenSource(ag) + if !ok { + return nil + } + var baseline json.RawMessage + if preState != nil { + baseline = preState.TokenBaseline + } + // A missing baseline means count-from-zero, which is only legitimate on + // the session's first tracked turn (no snapshot existed yet). Mid-session + // — after earlier turns already accumulated deltas — it means the + // PrePromptState was lost or corrupt: counting from zero would return + // session-cumulative totals and AccumulateSessionTokenUsage would re-add + // tokens the earlier turns already recorded. Degrade to no-data instead. + if len(baseline) == 0 { + if state, stateErr := strategy.LoadSessionState(ctx, sessionID); stateErr == nil && + state != nil && state.TokenUsage != nil && state.TokenUsage.APICallCount > 0 { + logging.Warn(logging.WithComponent(ctx, "lifecycle"), + "out-of-band token baseline missing mid-session; skipping this turn's token delta to avoid double counting", + slog.String("session_id", sessionID)) + return nil + } + } + oobUsage, oobErr := src.CalculateTokenUsageSince(ctx, sessionID, baseline) + if oobErr != nil { + logging.Warn(logging.WithComponent(ctx, "lifecycle"), "failed to compute out-of-band token usage", + slog.String("error", oobErr.Error())) + return nil + } + return oobUsage +} + +// shouldSuppressConditionalTurnStart reports whether a conditional TurnStart +// (Event.SuppressIfSessionActive, set by agents whose per-invocation hooks +// can't tell a follow-up model call from a resumed turn) must be dropped. Only +// a genuinely mid-turn session suppresses it — an idle/ended/condensed/absent +// session means the prior turn finished, so a new or resumed turn should be +// tracked. A crashed session (killed before its Stop hook fired) must NOT +// suppress — otherwise every resume of a crashed conversation would run +// untracked and uninitialized, computing its TurnEnd delta against the stale +// crashed-turn baseline. Crash detection is two-tier: OwnerExited catches a +// dead owner process immediately (PID liveness), and IsStuckActive covers the +// cases liveness can't see (no recorded owner, cross-host state) after +// session.StuckActiveThreshold of silence. +func shouldSuppressConditionalTurnStart(event *agent.Event, state *strategy.SessionState) bool { + return event.SuppressIfSessionActive && state != nil && state.Phase.IsActive() && + !state.IsStuckActive() && !state.OwnerExited() +} + // logFileChanges logs the files modified, created, and deleted during a session. func logFileChanges(ctx context.Context, modified, newFiles, deleted []string) { logCtx := logging.WithComponent(ctx, "lifecycle") diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index 7c2872322a..31fc5852de 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -28,6 +28,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/gitrepo" "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/proclive" "github.com/entireio/cli/cmd/entire/cli/provenance" "github.com/entireio/cli/cmd/entire/cli/review" "github.com/entireio/cli/cmd/entire/cli/session" @@ -123,7 +124,7 @@ var _ agent.TranscriptAnalyzer = (*mockAnalyzerAgent)(nil) func (m *mockAnalyzerAgent) GetTranscriptPosition(_ string) (int, error) { return 0, nil } -func (m *mockAnalyzerAgent) ExtractModifiedFilesFromOffset(_ string, _ int) ([]string, int, error) { +func (m *mockAnalyzerAgent) ExtractModifiedFilesFromOffset(_ context.Context, _ string, _ int) ([]string, int, error) { if m.onExtract != nil { m.onExtract() } @@ -391,6 +392,59 @@ func TestDispatchLifecycleEvent_NilEvent(t *testing.T) { } } +// mockOOBTokenAgent implements OutOfBandTokenSource on top of the standard +// lifecycle mock; CalculateTokenUsageSince returns a fixed value regardless of +// baseline, mimicking the count-from-zero behavior of a nil baseline. +type mockOOBTokenAgent struct { + mockLifecycleAgent + + usage *agent.TokenUsage +} + +func (m *mockOOBTokenAgent) SnapshotTokenBaseline(_ context.Context, _ string) (json.RawMessage, error) { + return nil, nil +} + +//nolint:unparam // error return is fixed by the OutOfBandTokenSource interface +func (m *mockOOBTokenAgent) CalculateTokenUsageSince(_ context.Context, _ string, _ json.RawMessage) (*agent.TokenUsage, error) { + return m.usage, nil +} + +// TestComputeOutOfBandTokenUsage_MissingBaselineMidSession pins the nil-baseline +// contract: a missing baseline is only legitimate on a session's first tracked +// turn (count from zero). Mid-session — after earlier turns already accumulated +// deltas — a lost/corrupt PrePromptState must degrade to no-data; counting from +// zero would return session-cumulative totals and double-count every earlier +// turn in entire status and the next checkpoint. +func TestComputeOutOfBandTokenUsage_MissingBaselineMidSession(t *testing.T) { + setupStopTestRepo(t) + ctx := context.Background() + + cumulative := &agent.TokenUsage{InputTokens: 700, OutputTokens: 500, APICallCount: 3} + ag := &mockOOBTokenAgent{usage: cumulative} + + sessionID := "test-oob-midsession" + require.NoError(t, strategy.SaveSessionState(ctx, &strategy.SessionState{ + SessionID: sessionID, + BaseCommit: "abc123", + StartedAt: time.Now(), + TokenUsage: &agent.TokenUsage{InputTokens: 250, OutputTokens: 280, APICallCount: 2}, + })) + + got := computeOutOfBandTokenUsage(ctx, ag, sessionID, nil) + require.Nil(t, got, "missing baseline mid-session must degrade to no-data, not count-from-zero") + + // First tracked turn (nothing accumulated yet): count-from-zero is the + // documented, correct behavior — the guard must not break it. + freshID := "test-oob-first-turn" + require.NoError(t, strategy.SaveSessionState(ctx, &strategy.SessionState{ + SessionID: freshID, + BaseCommit: "abc123", + StartedAt: time.Now(), + })) + require.Equal(t, cumulative, computeOutOfBandTokenUsage(ctx, ag, freshID, nil)) +} + // TestDispatchLifecycleEvent_SkipsForwardedHookFromNonOwningAgent verifies the // dispatcher-level dedup: when SessionState records a different owning agent, // non-SessionStart / non-TurnStart events from forwarded hooks no-op. This @@ -1076,6 +1130,52 @@ func TestHandleLifecycleTurnEnd_EmptyRepository(t *testing.T) { } } +func TestShouldSuppressConditionalTurnStart(t *testing.T) { + t.Parallel() + + now := time.Now() + stuckAt := now.Add(-2 * session.StuckActiveThreshold) + active := &strategy.SessionState{Phase: session.PhaseActive, StartedAt: now, LastInteractionTime: &now} + stuckActive := &strategy.SessionState{Phase: session.PhaseActive, StartedAt: stuckAt, LastInteractionTime: &stuckAt} + idle := &strategy.SessionState{Phase: session.PhaseIdle, StartedAt: now, LastInteractionTime: &now} + + // Conditional TurnStart (agy invocationNum>0) with an active mid-turn + // session is a follow-up model call — suppress so the baseline isn't clobbered. + if !shouldSuppressConditionalTurnStart(&agent.Event{Type: agent.TurnStart, SuppressIfSessionActive: true}, active) { + t.Error("conditional TurnStart with a recently-active session must be suppressed (follow-up invocation)") + } + // Stuck-ACTIVE (crashed session whose Stop never fired) => a resume must + // fire, or the crashed conversation is untracked forever. + if shouldSuppressConditionalTurnStart(&agent.Event{Type: agent.TurnStart, SuppressIfSessionActive: true}, stuckActive) { + t.Error("conditional TurnStart with a stuck-ACTIVE session must fire (resume after crash)") + } + // Dead owner (crash detected via PID liveness) => a resume must fire + // immediately, without waiting out StuckActiveThreshold. A mismatched + // start fingerprint on our own PID is proclive's deterministic "dead + // owner" signal on supported platforms. + deadOwner := &strategy.SessionState{ + Phase: session.PhaseActive, StartedAt: now, LastInteractionTime: &now, + Owner: &proclive.Identity{PID: os.Getpid(), Start: "bogus-start-fingerprint"}, + } + if deadOwner.OwnerLiveness() != proclive.LivenessDead { + t.Logf("skipping dead-owner case: liveness = %v on this platform", deadOwner.OwnerLiveness()) + } else if shouldSuppressConditionalTurnStart(&agent.Event{Type: agent.TurnStart, SuppressIfSessionActive: true}, deadOwner) { + t.Error("conditional TurnStart with a dead-owner ACTIVE session must fire (resume within the stuck threshold)") + } + // Idle session => the prior turn finished; a new/resumed turn must fire. + if shouldSuppressConditionalTurnStart(&agent.Event{Type: agent.TurnStart, SuppressIfSessionActive: true}, idle) { + t.Error("conditional TurnStart with an IDLE session must fire (new/resumed turn)") + } + // No session (condensed away / fresh) => resume must fire. + if shouldSuppressConditionalTurnStart(&agent.Event{Type: agent.TurnStart, SuppressIfSessionActive: true}, nil) { + t.Error("conditional TurnStart with no session must fire (resume after condensation)") + } + // Unconditional TurnStart (invocationNum==0) must never be suppressed. + if shouldSuppressConditionalTurnStart(&agent.Event{Type: agent.TurnStart, SuppressIfSessionActive: false}, active) { + t.Error("unconditional TurnStart must never be suppressed") + } +} + // TestTurnFlow_StatusBudgetBreachDegradesGracefully pins the zombie-hook // incident regression (stray `git init` in $HOME): when the worktree status // walk breaches its wall-clock budget, both turn hooks must still succeed — diff --git a/cmd/entire/cli/osroot/osroot.go b/cmd/entire/cli/osroot/osroot.go index 3abfaa93ba..7d8eae4691 100644 --- a/cmd/entire/cli/osroot/osroot.go +++ b/cmd/entire/cli/osroot/osroot.go @@ -296,6 +296,14 @@ func MkdirAllNoSymlink(root *os.Root, name string, perm os.FileMode) error { if !fs.ValidPath(name) { return fmt.Errorf("%q is not a valid root-relative path", name) } + // Names inside a root are slash-separated; this function splits on "/" + // only. A backslash is an ordinary character to io/fs, so a caller that + // built the name with filepath.Join on Windows would get ONE component here + // and a Mkdir against a parent that was never created. Refuse it loudly + // rather than fail later with an unexplained "path not found". + if strings.Contains(name, `\`) { + return fmt.Errorf("%q is not a slash-separated root-relative path", name) + } current := root var owned *os.Root diff --git a/cmd/entire/cli/osroot/osroot_test.go b/cmd/entire/cli/osroot/osroot_test.go index c971752432..0592e8a52c 100644 --- a/cmd/entire/cli/osroot/osroot_test.go +++ b/cmd/entire/cli/osroot/osroot_test.go @@ -5,6 +5,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" "testing" "github.com/entireio/cli/cmd/entire/cli/osroot" @@ -799,3 +800,31 @@ func TestOpenNoFollow_MissingStaysNotExist(t *testing.T) { t.Error("an absent file must not report ErrNotRegularFile") } } + +// A backslash-joined name is one component to os.Root and to this function's +// "/" split; on Windows that is exactly what filepath.Join produces, and it +// surfaced as a status store that was never created. Refuse it up front. +func TestMkdirAllNoSymlink_RejectsBackslashSeparatedName(t *testing.T) { + t.Parallel() + dir := t.TempDir() + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatal(err) + } + defer root.Close() + + err = osroot.MkdirAllNoSymlink(root, `antigravity\status`, 0o750) + if err == nil { + t.Fatal("expected a backslash-separated name to be refused") + } + if !strings.Contains(err.Error(), "slash-separated") { + t.Fatalf("error should explain the separator rule, got %v", err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("nothing may be created for a refused name, found %v", entries) + } +} diff --git a/cmd/entire/cli/osroot/rootbase_guard_test.go b/cmd/entire/cli/osroot/rootbase_guard_test.go index e3ff1cc215..e2777c7dfe 100644 --- a/cmd/entire/cli/osroot/rootbase_guard_test.go +++ b/cmd/entire/cli/osroot/rootbase_guard_test.go @@ -46,16 +46,18 @@ var allowedRootBases = map[string]string{ // Trees with their own resolver, anchored at the boundary between what // Entire owns and what it does not. - "cmd/entire/cli/agent/session_store.go": "the agent's own GetSessionDir (opened per operation, not memoized)", - "cmd/entire/cli/agent/vouched_dirs.go": "worktree root, or a symlinked agent directory the user vouched for in settings.local.json, resolved", - "cmd/entire/cli/strategy/hooks.go": "git rev-parse --git-path hooks; core.hooksPath can name a directory no other anchor covers", - "cmd/entire/cli/plugin_store.go": "pluginParentDir()", - "cmd/entire/cli/plugin_index.go": "the per-index cache dir, opened at the clone it contains", - "cmd/entire/cli/plugin_install_remote.go": "a staging dir this process just created", - "cmd/entire/cli/plugin_fetch.go": "the staging dir its caller created", - "cmd/entire/cli/utils.go": "one of worktree root / home / temp, chosen by containment", - "internal/entireclient/contexts/contexts.go": "the caller's config dir, not the contexts file's parent", - "internal/entireclient/discovery/cache.go": "the caller's cache dir, not the cache file's parent", + "cmd/entire/cli/agent/antigravity/statusline.go": "$ENTIRE_ANTIGRAVITY_STATUS_DIR, an operator override held to RequireAbsoluteOverride like userdirs' own; the default store is a name inside userdirs.CacheRoot", + "cmd/entire/cli/agent/antigravity/title_install.go": "agy's own config dir (~/.gemini/antigravity-cli, or the absolute $ENTIRE_ANTIGRAVITY_CONFIG_DIR override); settings.json is a name inside it", + "cmd/entire/cli/agent/session_store.go": "the agent's own GetSessionDir (opened per operation, not memoized)", + "cmd/entire/cli/agent/vouched_dirs.go": "worktree root, or a symlinked agent directory the user vouched for in settings.local.json, resolved", + "cmd/entire/cli/strategy/hooks.go": "git rev-parse --git-path hooks; core.hooksPath can name a directory no other anchor covers", + "cmd/entire/cli/plugin_store.go": "pluginParentDir()", + "cmd/entire/cli/plugin_index.go": "the per-index cache dir, opened at the clone it contains", + "cmd/entire/cli/plugin_install_remote.go": "a staging dir this process just created", + "cmd/entire/cli/plugin_fetch.go": "the staging dir its caller created", + "cmd/entire/cli/utils.go": "one of worktree root / home / temp, chosen by containment", + "internal/entireclient/contexts/contexts.go": "the caller's config dir, not the contexts file's parent", + "internal/entireclient/discovery/cache.go": "the caller's cache dir, not the cache file's parent", // The two deliberate exceptions, both on a path the CALLER named, where // the file's parent IS the caller's choice and no other base exists. Each diff --git a/cmd/entire/cli/repo_mirror_request_test.go b/cmd/entire/cli/repo_mirror_request_test.go index fca28a5fa8..1daeb5d6fe 100644 --- a/cmd/entire/cli/repo_mirror_request_test.go +++ b/cmd/entire/cli/repo_mirror_request_test.go @@ -368,14 +368,17 @@ func TestCreateAndAwaitMirror_AsyncResubmission(t *testing.T) { useFastMirrorPolling(t) t.Run("resubmission after transport failure reuses the placement", func(t *testing.T) { - submissions := 0 - placements := 0 + // Atomic, not plain ints: httptest serves each request on its own + // goroutine, and the aborted first request's handler can still be + // unwinding when the resubmission's handler runs, so two handler + // goroutines touch these counters. The race detector flagged exactly + // that in CI. + var submissions, placements atomic.Int32 client := newMirrorRequestClient(t, func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case mirrorRequestsAPIPath: - submissions++ - if submissions == 1 { - placements++ + if submissions.Add(1) == 1 { + placements.Add(1) panic(http.ErrAbortHandler) } writeAcceptedMirrorRequest(t, w) @@ -395,8 +398,8 @@ func TestCreateAndAwaitMirror_AsyncResubmission(t *testing.T) { }) require.NoError(t, err) require.Equal(t, "mirror-1", outcome.created.MirrorId) - require.Equal(t, 1, placements) - require.Equal(t, 2, submissions) + require.Equal(t, int32(1), placements.Load()) + require.Equal(t, int32(2), submissions.Load()) }) } diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index e4dff868d6..dbaeeb916a 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -232,6 +232,16 @@ type State struct { // against this value without reading the full transcript content. CheckpointTranscriptSize int64 `json:"checkpoint_transcript_size,omitempty"` + // TranscriptOffsetPending records that the turn-end advance of + // CheckpointTranscriptStart could not run because a late-transcript agent + // (agent.LateTranscriptWriter, e.g. Antigravity) had not flushed its + // transcript by the Stop hook. Everything the flush will eventually write + // is already condensed, so the next mid-turn condensation resolves the + // pending advance against the by-then-flushed file — without this, prompts + // and the scoped transcript for the next checkpoint would start inside the + // previous (already-condensed) turn, attributing the wrong prompt to it. + TranscriptOffsetPending bool `json:"transcript_offset_pending,omitempty"` + // Deprecated: CondensedTranscriptLines is replaced by CheckpointTranscriptStart. // Kept for backward compatibility with existing state files. // Use NormalizeAfterLoad() to migrate. diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index ee2cd33840..92bc00fa2d 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -15,6 +15,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/antigravity" codexagent "github.com/entireio/cli/cmd/entire/cli/agent/codex" "github.com/entireio/cli/cmd/entire/cli/agent/external" "github.com/entireio/cli/cmd/entire/cli/agent/types" @@ -892,7 +893,7 @@ Examples: cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push") cmd.Flags().StringVar(&opts.CheckpointRemote, flagCheckpointRemote, "", checkpointRemoteFlagUsage) cmd.Flags().StringVar(&opts.CheckpointBackend, flagCheckpointBackend, "", checkpointBackendFlagUsage) - cmd.Flags().StringVar(&summarizeProvider, flagSummarizeAgent, "", "Set the provider used by explain --generate (e.g., claude-code, codex, pi, opencode, cursor, copilot-cli)") + cmd.Flags().StringVar(&summarizeProvider, flagSummarizeAgent, "", "Set the provider used by explain --generate (e.g., claude-code, codex, antigravity, pi, opencode, cursor, copilot-cli)") cmd.Flags().StringVar(&summarizeModel, flagSummarizeModel, "", "Set the model hint used by explain --generate") cmd.Flags().IntVar(&summarizeTimeoutSeconds, flagSummarizeTimeout, 0, "Set the hard deadline (seconds) for explain --generate summary generation. 0 clears the setting, leaving summary generation unbounded.") cmd.Flags().BoolVar(&opts.Telemetry, flagTelemetry, true, "Enable anonymous usage analytics") @@ -1822,7 +1823,32 @@ func runRemoveAgent(ctx context.Context, w io.Writer, name string) error { } warnCodexHooksAfterRemoval(ctx, w, ag) + // Antigravity's title tee lives in agy's GLOBAL settings.json, not in + // this repo. `entire agent remove` is itself a per-repo command (it edits + // only this repo's .agents/hooks.json), and there is no machine-wide + // "remove Antigravity everywhere" command, so this is the one place the + // global slot is released: `entire disable` deliberately leaves it alone. + // The cost is real and stated to the user below — removing the tee here + // disables token capture for every OTHER repo still using Antigravity + // until `entire agent add antigravity` (or doctor) repairs it there. + // Counting the repos that still depend on the slot before releasing it + // is a deferred product decision, tracked on trail 444. + teeRemoved := false + if ag.Name() == agent.AgentNameAntigravity && antigravity.TitleTeeInstalled() { + if err := antigravity.UninstallTitleTee(); err != nil { + logging.Warn(ctx, "failed to uninstall antigravity title tee", + "error", err.Error()) + } else { + teeRemoved = true + } + } + fmt.Fprintf(w, "Removed %s hooks.\n", ag.Type()) + if teeRemoved { + fmt.Fprintln(w, "Note: the Antigravity title-tee was removed from agy's global settings —") + fmt.Fprintln(w, "this disables token capture in any other repositories still using Antigravity.") + fmt.Fprintln(w, "Run `entire agent add antigravity` (or `entire doctor`) there to restore it.") + } return nil } diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 82010bb771..8b473b380e 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -5308,6 +5308,48 @@ func TestCleanRemoteURLForReport(t *testing.T) { } } +// TestRunRemoveAgent_AntigravityWarnsAboutGlobalTeeRemoval pins the +// machine-global side effect of a repo-scoped command: removing the +// Antigravity agent uninstalls the title-tee from agy's GLOBAL settings, which +// disables token capture for every other repo still using Antigravity. The +// user must be told, since the breakage is otherwise silent (zero-token +// checkpoints) until doctor or agent add runs in the other repo. +func TestRunRemoveAgent_AntigravityWarnsAboutGlobalTeeRemoval(t *testing.T) { + dir := setupGitRepoForPhaseTest(t) + t.Chdir(dir) + + agentsDir := filepath.Join(dir, ".agents") + if err := os.MkdirAll(agentsDir, 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentsDir, "hooks.json"), []byte(antigravityHooksJSON()), 0o600); err != nil { + t.Fatal(err) + } + + cfgDir := filepath.Join(t.TempDir(), "agy") + if err := os.MkdirAll(cfgDir, 0o750); err != nil { + t.Fatal(err) + } + teeSettings := `{"title":{"type":"command","command":"entire hooks antigravity title-tee"}}` + if err := os.WriteFile(filepath.Join(cfgDir, "settings.json"), []byte(teeSettings), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("ENTIRE_ANTIGRAVITY_CONFIG_DIR", cfgDir) + + var out bytes.Buffer + if err := runRemoveAgent(context.Background(), &out, "antigravity"); err != nil { + t.Fatalf("runRemoveAgent: %v", err) + } + + got := out.String() + if !strings.Contains(got, "Removed Antigravity hooks.") { + t.Errorf("missing removal confirmation: %q", got) + } + if !strings.Contains(got, "token capture") || !strings.Contains(got, "other repositories") { + t.Errorf("missing global title-tee removal warning: %q", got) + } +} + // First-time setups get the git-refs checkpoint backend written explicitly // into the new settings.json — new users must not answer a storage-topology // question (the old wizard prompt), and the explicit write is what keeps the diff --git a/cmd/entire/cli/state.go b/cmd/entire/cli/state.go index f1c086333c..7fafcc208d 100644 --- a/cmd/entire/cli/state.go +++ b/cmd/entire/cli/state.go @@ -62,6 +62,13 @@ type PrePromptState struct { // Deprecated: LastTranscriptLineCount is the oldest name for transcript position. // Migrated to TranscriptOffset on load. LastTranscriptLineCount int `json:"last_transcript_line_count,omitempty"` + + // TokenBaseline is an opaque, agent-defined token position captured at turn + // start for OutOfBandTokenSource agents (currently Antigravity). At TurnEnd + // the lifecycle passes it back to CalculateTokenUsageSince to compute the + // checkpoint-scoped token delta. Empty for agents with transcript-embedded + // token data. + TokenBaseline json.RawMessage `json:"token_baseline,omitempty"` } // PreUntrackedFiles returns the untracked files list, or nil if the receiver is nil. @@ -152,6 +159,18 @@ func CapturePrePromptState(ctx context.Context, ag agent.Agent, sessionID, sessi TranscriptOffset: transcriptOffset, } + // Out-of-band token baseline: agents whose token usage lives outside the + // transcript (Antigravity) snapshot their cumulative token position now so + // TurnEnd can compute the per-checkpoint delta. + if src, ok := agent.AsOutOfBandTokenSource(ag); ok { + baseline, blErr := src.SnapshotTokenBaseline(ctx, sessionID) + if blErr != nil { + logging.Warn(logging.WithComponent(ctx, "state"), "failed to snapshot out-of-band token baseline", "error", blErr.Error()) + } else { + state.TokenBaseline = baseline + } + } + data, err := jsonutil.MarshalIndentWithNewline(state, "", " ") if err != nil { return fmt.Errorf("failed to marshal state: %w", err) diff --git a/cmd/entire/cli/strategy/accumulate_token_usage_test.go b/cmd/entire/cli/strategy/accumulate_token_usage_test.go new file mode 100644 index 0000000000..d20c1d05b5 --- /dev/null +++ b/cmd/entire/cli/strategy/accumulate_token_usage_test.go @@ -0,0 +1,52 @@ +package strategy + +import ( + "context" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + agenttypes "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/stretchr/testify/require" +) + +// TestAccumulateSessionTokenUsage verifies the helper mirrors SaveStep's token +// accounting (accumulate into BOTH the session-cumulative TokenUsage and the +// checkpoint-scoped CheckpointTokenUsage). It exists for turns that end with +// no uncommitted changes (e.g. Antigravity committing all its work mid-turn): +// SaveStep is skipped, but the turn's out-of-band token delta must still be +// recorded so the next condensation attributes it. +func TestAccumulateSessionTokenUsage(t *testing.T) { + dir := t.TempDir() + testutil.InitRepo(t, dir) + t.Chdir(dir) + + sessionID := "agy-accumulate-tokens" + now := time.Now() + state := &SessionState{ + SessionID: sessionID, + AgentType: agenttypes.AgentType("Antigravity"), + StartedAt: now, // zero StartedAt would be auto-deleted as stale on load + LastInteractionTime: &now, + TokenUsage: &agent.TokenUsage{ + InputTokens: 100, OutputTokens: 10, APICallCount: 1, + }, + } + require.NoError(t, SaveSessionState(context.Background(), state)) + + delta := &agent.TokenUsage{InputTokens: 50, OutputTokens: 5, APICallCount: 1} + require.NoError(t, AccumulateSessionTokenUsage(context.Background(), sessionID, delta)) + + got, err := LoadSessionState(context.Background(), sessionID) + require.NoError(t, err) + require.NotNil(t, got.TokenUsage) + require.Equal(t, 150, got.TokenUsage.InputTokens, "session-cumulative input") + require.Equal(t, 15, got.TokenUsage.OutputTokens, "session-cumulative output") + require.Equal(t, 2, got.TokenUsage.APICallCount, "session-cumulative calls") + require.NotNil(t, got.CheckpointTokenUsage, "checkpoint-scoped accumulator must be populated") + require.Equal(t, 50, got.CheckpointTokenUsage.InputTokens, "checkpoint-scoped input") + + // Nil delta is a no-op, not an error. + require.NoError(t, AccumulateSessionTokenUsage(context.Background(), sessionID, nil)) +} diff --git a/cmd/entire/cli/strategy/condensation_prompts_test.go b/cmd/entire/cli/strategy/condensation_prompts_test.go new file mode 100644 index 0000000000..3ec5cbe3dd --- /dev/null +++ b/cmd/entire/cli/strategy/condensation_prompts_test.go @@ -0,0 +1,91 @@ +package strategy + +import ( + "context" + "os" + "path/filepath" + "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/gitrepo" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/testutil" + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/stretchr/testify/require" +) + +// The prompt ladder's last rung must work from the transcript BYTES being +// checkpointed. When condensation fell back to the shadow-branch copy because +// the live path could not be read, a re-read of the path found nothing and the +// checkpoint carried a full transcript with no prompt. +func TestResolveCondensationPrompts_UsesTranscriptBytesWhenLivePathIsGone(t *testing.T) { + t.Parallel() + ag := antigravity.NewAntigravityAgent() + transcript := []byte(`{"type":"USER_INPUT","content":"\nfirst ask\n"} +{"type":"PLANNER_RESPONSE"} +{"type":"USER_INPUT","content":"\nadd another\n"} +`) + missing := filepath.Join(t.TempDir(), "gone.jsonl") + + got := resolveCondensationPrompts(context.Background(), ag, transcript, missing, 2) + require.Equal(t, []string{"add another"}, got) + + // Without bytes in hand the rung degrades to the path-based read, which + // finds nothing here — the behaviour this test exists to stop relying on. + require.Nil(t, resolveCondensationPrompts(context.Background(), ag, nil, missing, 2)) +} + +// buildShadowRepo commits files into a throwaway repo and returns it with the +// commit hash, so extractSessionData can be driven against a crafted tree. +func buildShadowRepo(t *testing.T, files map[string]string) (*git.Repository, plumbing.Hash) { + t.Helper() + dir := t.TempDir() + testutil.InitRepo(t, dir) + repo, err := gitrepo.OpenPath(dir) + require.NoError(t, err) + + for path, content := range files { + abs := filepath.Join(dir, filepath.FromSlash(path)) + require.NoError(t, os.MkdirAll(filepath.Dir(abs), 0o750)) + require.NoError(t, os.WriteFile(abs, []byte(content), 0o600)) + } + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add(".") + require.NoError(t, err) + hash, err := wt.Commit("shadow", &git.CommitOptions{ + Author: &object.Signature{Name: "Test", Email: "test@test.com"}, + }) + require.NoError(t, err) + return repo, hash +} + +// TestExtractSessionData_ResolvesPromptsWithEmptyTranscript pins the prompt +// ladder OUTSIDE the transcript gate on the shadow-branch path. A +// LateTranscriptWriter routinely checkpoints an empty placeholder mid-turn; +// while the ladder sat inside `if fullTranscript != ""` that produced a +// checkpoint with no prompt from ANY source — not even the prompt.txt sitting +// in the very tree being read — and silenced logCondensationPrompts with it. +func TestExtractSessionData_ResolvesPromptsWithEmptyTranscript(t *testing.T) { + t.Parallel() + + const sessionID = "20260924-agy-empty-transcript" + metadataDir := paths.SessionMetadataDirFromSessionID(sessionID) + repo, hash := buildShadowRepo(t, map[string]string{ + // The empty placeholder PrepareTranscript materialises when Stop beats + // agy's flush, exactly as SaveStep checkpointed it. + metadataDir + "/" + paths.TranscriptFileName: "", + metadataDir + "/" + paths.PromptFileName: "the prompt that must survive", + }) + + s := &ManualCommitStrategy{} + data, err := s.extractSessionData(context.Background(), repo, hash, sessionID, nil, + agent.AgentTypeAntigravity, "", 0, false) + require.NoError(t, err) + require.Empty(t, data.Transcript, "sanity: this is the empty-transcript case") + require.Equal(t, []string{"the prompt that must survive"}, data.Prompts, + "prompt resolution must not depend on transcript content") +} diff --git a/cmd/entire/cli/strategy/condense_skip_test.go b/cmd/entire/cli/strategy/condense_skip_test.go index 0186bd35fd..08ef1993a8 100644 --- a/cmd/entire/cli/strategy/condense_skip_test.go +++ b/cmd/entire/cli/strategy/condense_skip_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/redact" @@ -387,6 +388,56 @@ func TestTryAgentCommitFastPath_SkipsEmptySession(t *testing.T) { assert.NotContains(t, string(content), "Entire-Checkpoint", "should not add trailer for empty session") } +func TestTryAgentCommitFastPath_SkipsAntigravityWithUnflushedTranscript(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + s := &ManualCommitStrategy{} + + commitMsgFile := filepath.Join(dir, "COMMIT_EDITMSG") + require.NoError(t, os.WriteFile(commitMsgFile, []byte("test commit\n"), 0o644)) + + // agy writes its transcript only after Stop: mid-turn the recorded path + // points at a missing (or empty placeholder) file. With no tracked files + // and no shadow branch, condensation degrades to an empty transcript and + // the skip gate fires — a stamped trailer would dangle permanently. + emptyTranscript := filepath.Join(dir, "transcript_full.jsonl") + require.NoError(t, os.WriteFile(emptyTranscript, nil, 0o600)) + + for name, path := range map[string]string{ + "missing transcript file": filepath.Join(dir, "does-not-exist.jsonl"), + "empty transcript file": emptyTranscript, + } { + agySession := &SessionState{ + SessionID: "agy-midturn-" + name, + AgentType: agent.AgentTypeAntigravity, + Phase: session.PhaseActive, + TranscriptPath: path, + } + result := s.tryAgentCommitFastPath(context.Background(), commitMsgFile, []*SessionState{agySession}, "message") + assert.False(t, result, "fast path must not fire for agy with %s", name) + } + + content, err := os.ReadFile(commitMsgFile) + require.NoError(t, err) + assert.NotContains(t, string(content), "Entire-Checkpoint", "no trailer for agy sessions condensation would skip") + + // Once the transcript has real content, the trailer is stamped as usual. + populated := filepath.Join(dir, "transcript_populated.jsonl") + require.NoError(t, os.WriteFile(populated, []byte(`{"type":"USER_INPUT","content":"hi"}`+"\n"), 0o600)) + agySession := &SessionState{ + SessionID: "agy-midturn-populated", + AgentType: agent.AgentTypeAntigravity, + Phase: session.PhaseActive, + TranscriptPath: populated, + } + result := s.tryAgentCommitFastPath(context.Background(), commitMsgFile, []*SessionState{agySession}, "message") + assert.True(t, result, "fast path should fire for agy with a flushed transcript") + content, err = os.ReadFile(commitMsgFile) + require.NoError(t, err) + assert.Contains(t, string(content), "Entire-Checkpoint") +} + func TestTryAgentCommitFastPath_AcceptsSessionWithContent(t *testing.T) { dir := setupGitRepo(t) t.Chdir(dir) diff --git a/cmd/entire/cli/strategy/count_transcript_antigravity_test.go b/cmd/entire/cli/strategy/count_transcript_antigravity_test.go new file mode 100644 index 0000000000..b35e4050be --- /dev/null +++ b/cmd/entire/cli/strategy/count_transcript_antigravity_test.go @@ -0,0 +1,41 @@ +package strategy + +import ( + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/antigravity" + "github.com/stretchr/testify/require" +) + +// TestCountTranscriptItems_AntigravitySkipsBlankLines verifies that the offset +// written into CheckpointTranscriptStart uses the same metric Antigravity's +// readers use. ExtractPrompts and GetTranscriptPosition skip blank lines before +// counting (the codex splitJSONL convention); if countTranscriptItems counted +// raw lines, an interior blank line would make the stored offset overshoot and +// resolvePromptsFromLateFlushedTranscript would silently drop the first +// prompt(s) of the next checkpoint. +func TestCountTranscriptItems_AntigravitySkipsBlankLines(t *testing.T) { + t.Parallel() + + content := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"hi"} + +{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","content":"ok"} +` + got := countTranscriptItems(agent.AgentTypeAntigravity, content) + require.Equal(t, 2, got, "antigravity count must skip interior blank lines to match ExtractPrompts/GetTranscriptPosition") + + // Cross-check against the agent's own position metric — the two must agree, + // since one is written into CheckpointTranscriptStart and the other reads it. + ag := antigravity.NewAntigravityAgent() + analyzer, ok := agent.AsTranscriptAnalyzer(ag) + require.True(t, ok) + dir := t.TempDir() + path := filepath.Join(dir, "transcript_full.jsonl") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + pos, err := analyzer.GetTranscriptPosition(path) + require.NoError(t, err) + require.Equal(t, pos, got, "countTranscriptItems and GetTranscriptPosition must use the same metric") +} diff --git a/cmd/entire/cli/strategy/late_flush_prompt_test.go b/cmd/entire/cli/strategy/late_flush_prompt_test.go new file mode 100644 index 0000000000..b0f49f7377 --- /dev/null +++ b/cmd/entire/cli/strategy/late_flush_prompt_test.go @@ -0,0 +1,53 @@ +package strategy + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/antigravity" + "github.com/stretchr/testify/require" +) + +// TestResolvePromptsFromLateFlushedTranscript verifies that the condensation-time +// fallback re-extracts user prompts directly from a populated transcript via the +// agent's PromptExtractor. This covers late-flushing agents (e.g. Antigravity) +// whose transcript is empty at TurnEnd but populated by condensation time, so +// prompt.txt is empty and the only remaining source is the live transcript. +func TestResolvePromptsFromLateFlushedTranscript(t *testing.T) { + t.Parallel() + + // Real agy transcript with a USER_INPUT step wrapping the prompt in a + // block, matching agy's on-disk step schema. + transcript := `{"step_index":0,"source":"SYSTEM","type":"CONVERSATION_HISTORY","content":"boot"} +{"step_index":1,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"Add a login button"} +{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","content":"working on it"} +` + dir := t.TempDir() + transcriptPath := filepath.Join(dir, "transcript.jsonl") + require.NoError(t, os.WriteFile(transcriptPath, []byte(transcript), 0o600)) + + ag := antigravity.NewAntigravityAgent() + // Sanity: the real agent must be a PromptExtractor for this fallback to fire. + _, ok := agent.AsPromptExtractor(ag) + require.True(t, ok, "antigravity agent must implement PromptExtractor") + + got := resolvePromptsFromLateFlushedTranscript(context.Background(), ag, transcriptPath, 0) + require.Equal(t, []string{"Add a login button"}, got) +} + +// TestResolvePromptsFromLateFlushedTranscript_Guards verifies the helper returns +// nil for the no-op cases callers rely on (empty path, non-extractor agent). +func TestResolvePromptsFromLateFlushedTranscript_Guards(t *testing.T) { + t.Parallel() + + ag := antigravity.NewAntigravityAgent() + + // Empty path → nil, no extraction attempted. + require.Nil(t, resolvePromptsFromLateFlushedTranscript(context.Background(), ag, "", 0)) + + // Nil agent → nil (AsPromptExtractor returns false). + require.Nil(t, resolvePromptsFromLateFlushedTranscript(context.Background(), nil, "/nonexistent", 0)) +} diff --git a/cmd/entire/cli/strategy/live_transcript_empty_test.go b/cmd/entire/cli/strategy/live_transcript_empty_test.go new file mode 100644 index 0000000000..c126ff0ab1 --- /dev/null +++ b/cmd/entire/cli/strategy/live_transcript_empty_test.go @@ -0,0 +1,66 @@ +package strategy + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/stretchr/testify/require" +) + +// TestExtractSessionDataFromLiveTranscript_EmptyTranscriptDegrades verifies the +// first-turn condensation path tolerates an empty live transcript instead of +// hard-erroring. Antigravity writes its transcript AFTER the Stop hook, so a +// mid-turn commit on the first turn condenses while the transcript is still an +// empty placeholder (created by PrepareTranscript). Erroring here happens AFTER +// prepare-commit-msg stamped the Entire-Checkpoint trailer — the commit would +// permanently reference a checkpoint that was never written. The shadow-branch +// path already tolerates empty transcripts; the live path must degrade the same +// way: empty transcript content, FilesTouched preserved from hook capture. +func TestExtractSessionDataFromLiveTranscript_EmptyTranscriptDegrades(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + transcriptPath := filepath.Join(dir, "transcript_full.jsonl") + require.NoError(t, os.WriteFile(transcriptPath, nil, 0o600)) // empty placeholder + + s := &ManualCommitStrategy{} + state := &SessionState{ + SessionID: "agy-empty-live-test", + AgentType: agent.AgentTypeAntigravity, + TranscriptPath: transcriptPath, + FilesTouched: []string{"docs/blue.md"}, + } + + data, err := s.extractSessionDataFromLiveTranscript(context.Background(), state) + require.NoError(t, err, "empty live transcript must degrade, not fail — a hard error strands the already-stamped Entire-Checkpoint trailer") + require.NotNil(t, data) + require.Equal(t, []string{"docs/blue.md"}, data.FilesTouched, "hook-captured files must survive an empty transcript") + require.Empty(t, data.Transcript) +} + +// TestExtractSessionDataFromLiveTranscript_EmptyTranscriptErrorsForOtherAgents +// pins the inverse: for agents that do NOT write their transcript after the +// Stop hook, an empty live transcript is a transient race and must error so +// the failed condensation leaves session state untouched and the next commit +// retries with the populated transcript. +func TestExtractSessionDataFromLiveTranscript_EmptyTranscriptErrorsForOtherAgents(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + transcriptPath := filepath.Join(dir, "transcript.jsonl") + require.NoError(t, os.WriteFile(transcriptPath, nil, 0o600)) + + s := &ManualCommitStrategy{} + state := &SessionState{ + SessionID: "claude-empty-live-test", + AgentType: agent.AgentTypeClaudeCode, + TranscriptPath: transcriptPath, + FilesTouched: []string{"a.txt"}, + } + + _, err := s.extractSessionDataFromLiveTranscript(context.Background(), state) + require.Error(t, err, "non-late-flush agents must keep the error/retry invariant on an empty live transcript") +} diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index e78adbeda7..1ee8fa06d2 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -527,6 +527,10 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re // Errors are ignored; downstream readers handle missing transcripts gracefully. resolveTranscriptPath(state) //nolint:errcheck,gosec // best-effort; downstream readers handle missing files + // Complete a turn-end offset advance that lost the transcript flush race + // (late-transcript agents) before any offset-scoped extraction below. + resolvePendingTranscriptOffset(logCtx, ag, state) + extractStart := time.Now() _, extractSessionDataSpan := perf.Start(ctx, "extract_session_data") var shadowHash plumbing.Hash @@ -542,23 +546,7 @@ func (s *ManualCommitStrategy) CondenseSession(ctx context.Context, repo *git.Re extractSessionDataSpan.End() extractDuration := time.Since(extractStart) - // Backfill session state token usage from the freshly-extracted transcript. - // Copilot CLI writes session.shutdown after the hooks return, so by condensation - // time we can recover the authoritative full-session total from the transcript - // while keeping checkpoint metadata scoped to CheckpointTranscriptStart. The - // recompute drops SubagentTokens (subagentsDir=""); the helper preserves the - // cumulative subagent total across the backfill so resetCheckpointWindow's - // baseline does not regress to nil (finding 019f5ebf-a57e). - applyBackfilledSessionTokenUsage(ctx, ag, state, sessionData.Transcript, sessionData.TokenUsage) - - if !hasTokenUsageData(sessionData.TokenUsage) && hasTokenUsageData(state.CheckpointTokenUsage) { - // Whole-value fallback: accumulateTokenUsage already carries SubagentTokens. - sessionData.TokenUsage = accumulateTokenUsage(nil, state.CheckpointTokenUsage) - } else { - // Refill only the subagent total the recompute dropped. Runs after - // applyBackfilledSessionTokenUsage, which needs the usage without it. - sessionData.TokenUsage = fillMissingSubagentTokensFrom(sessionData.TokenUsage, state.CheckpointTokenUsage) - } + resolveCondensedTokenUsage(ctx, ag, state, sessionData) // Backfill the model from the transcript for agents that don't report it via // hooks (e.g., Pi records message.model but its hook events carry no model @@ -968,6 +956,22 @@ func (s *ManualCommitStrategy) extractOrCreateSessionData(ctx context.Context, r } } +// sliceByAgentMetric scopes a transcript through the agent's own offset metric +// when it declares one, so the slice and the stored offset share a counting +// rule. ok is false for agents without the capability, which scope by +// transcript format instead. +func sliceByAgentMetric(agentType types.AgentType, content []byte, startOffset int) (scoped []byte, ok bool) { + ag, err := agent.GetByAgentType(agentType) + if err != nil { + return nil, false + } + lw, ok := agent.AsLateTranscriptWriter(ag) + if !ok { + return nil, false + } + return lw.SliceTranscriptFromPosition(content, startOffset), true +} + // generateSummary produces an LLM-generated summary of the session transcript. // The transcript must be pre-redacted to avoid sending secrets to the LLM. // Returns nil if the scoped transcript is empty or generation fails. @@ -976,27 +980,40 @@ func generateSummary(ctx context.Context, redactedTranscript redact.RedactedByte transcriptBytes := redactedTranscript.Bytes() var scopedTranscript []byte - switch state.AgentType { - case agent.AgentTypeGemini: - // No new Gemini sessions start, but one still in the session store when - // Gemini CLI support was removed is condensed on the next commit. - scoped, sliceErr := geminilegacy.SliceFromMessage(transcriptBytes, state.CheckpointTranscriptStart) - if sliceErr != nil { - logging.Warn(summarizeCtx, "failed to scope Gemini transcript for summary", - slog.String("session_id", state.SessionID), - slog.String("error", sliceErr.Error())) - } + // Late-transcript agents own their offset metric, so they own the slice + // too: CheckpointTranscriptStart was produced by the agent's own counting + // rule (CountTranscriptPosition), and re-deriving the position here with a + // generic line slicer reintroduces exactly the drift that rule exists to + // prevent. Same delegation the counting side already uses. + if scoped, viaAgentMetric := sliceByAgentMetric(state.AgentType, transcriptBytes, state.CheckpointTranscriptStart); viaAgentMetric { scopedTranscript = scoped - case agent.AgentTypeOpenCode: - scoped, sliceErr := opencode.SliceFromMessage(transcriptBytes, state.CheckpointTranscriptStart) - if sliceErr != nil { - logging.Warn(summarizeCtx, "failed to scope OpenCode transcript for summary", - slog.String("session_id", state.SessionID), - slog.String("error", sliceErr.Error())) + } else { + switch state.AgentType { + case agent.AgentTypeGemini: + // No new Gemini sessions start, but one still in the session store when + // Gemini CLI support was removed is condensed on the next commit. + scoped, sliceErr := geminilegacy.SliceFromMessage(transcriptBytes, state.CheckpointTranscriptStart) + if sliceErr != nil { + logging.Warn(summarizeCtx, "failed to scope Gemini transcript for summary", + slog.String("session_id", state.SessionID), + slog.String("error", sliceErr.Error())) + } + scopedTranscript = scoped + case agent.AgentTypeOpenCode: + scoped, sliceErr := opencode.SliceFromMessage(transcriptBytes, state.CheckpointTranscriptStart) + if sliceErr != nil { + logging.Warn(summarizeCtx, "failed to scope OpenCode transcript for summary", + slog.String("session_id", state.SessionID), + slog.String("error", sliceErr.Error())) + } + scopedTranscript = scoped + case agent.AgentTypeCodex, agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeFactoryAIDroid, agent.AgentTypeAntigravity, agent.AgentTypeUnknown: + // Plain JSONL: one record per line, so the stored offset is a + // line count. Antigravity is listed only to keep the switch + // exhaustive — it scopes through its own metric above, and + // reaches this line only if its registry lookup fails. + scopedTranscript = transcript.SliceFromLine(transcriptBytes, state.CheckpointTranscriptStart) } - scopedTranscript = scoped - case agent.AgentTypeCodex, agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown: - scopedTranscript = transcript.SliceFromLine(transcriptBytes, state.CheckpointTranscriptStart) } if len(scopedTranscript) == 0 { @@ -1106,6 +1123,46 @@ func buildSessionMetrics(state *SessionState) *cpkg.SessionMetrics { } } +// resolveCondensedTokenUsage settles the token usage that goes into the +// condensed checkpoint metadata (sessionData.TokenUsage) and backfills the +// session-state total, applying the per-source fallbacks in priority order: +// +// 1. Session-state backfill from the freshly-extracted transcript: Copilot +// CLI writes session.shutdown after the hooks return, so by condensation +// time the authoritative full-session total is recoverable while +// checkpoint metadata stays scoped to CheckpointTranscriptStart. +// 2. Accumulated per-checkpoint usage (state.CheckpointTokenUsage, reset at +// every condensation). This is what carries out-of-band token counts +// (e.g. Antigravity, whose transcript has no token data — SaveStep +// accumulates the title-tee delta here). Deliberately NOT +// state.TokenUsage: that is the session-cumulative total, which is never +// reset at condensation and would double-count earlier turns on every +// checkpoint after the first. +// Known limitation (mid-turn commits): the out-of-band baseline only +// re-snapshots at TurnStart, so a mid-turn commit condenses with zero +// tokens and the whole turn's delta lands on the next condensation. +// Totals across the turn remain correct; only per-checkpoint scoping is +// coarse. +func resolveCondensedTokenUsage(ctx context.Context, ag agent.Agent, state *SessionState, sessionData *ExtractedSessionData) { + // Backfill session state token usage from the freshly-extracted transcript. + // Copilot CLI writes session.shutdown after the hooks return, so by condensation + // time we can recover the authoritative full-session total from the transcript + // while keeping checkpoint metadata scoped to CheckpointTranscriptStart. The + // recompute drops SubagentTokens (subagentsDir=""); the helper preserves the + // cumulative subagent total across the backfill so resetCheckpointWindow's + // baseline does not regress to nil (finding 019f5ebf-a57e). + applyBackfilledSessionTokenUsage(ctx, ag, state, sessionData.Transcript, sessionData.TokenUsage) + + if !hasTokenUsageData(sessionData.TokenUsage) && hasTokenUsageData(state.CheckpointTokenUsage) { + // Whole-value fallback: accumulateTokenUsage already carries SubagentTokens. + sessionData.TokenUsage = accumulateTokenUsage(nil, state.CheckpointTokenUsage) + } else { + // Refill only the subagent total the recompute dropped. Runs after + // applyBackfilledSessionTokenUsage, which needs the usage without it. + sessionData.TokenUsage = fillMissingSubagentTokensFrom(sessionData.TokenUsage, state.CheckpointTokenUsage) + } +} + func hasTokenUsageData(usage *agent.TokenUsage) bool { if usage == nil { return false @@ -1436,17 +1493,38 @@ func (s *ManualCommitStrategy) extractSessionData(ctx context.Context, repo *git if fullTranscript != "" { data.Transcript = []byte(fullTranscript) data.FullTranscriptLines = countTranscriptItems(agentType, fullTranscript) - // Read prompts from shadow branch tree (source of truth after SaveStep) - if file, fileErr := tree.File(metadataDir + "/" + paths.PromptFileName); fileErr == nil { - if content, contentErr := file.Contents(); contentErr == nil && content != "" { - data.Prompts = splitPromptContent(content) - } - } - // Filesystem fallback (written at turn start, covers mid-turn commits) - if len(data.Prompts) == 0 { - data.Prompts = readPromptsFromFilesystem(ctx, sessionID) + } + + // Prompt resolution sits OUTSIDE the transcript gate: no rung below needs + // transcript content to answer. The shadow tree's prompt.txt and the + // filesystem copy are both written independently of it, and + // resolveCondensationPrompts falls back to reading the transcript path when + // the bytes are empty. Gating them recorded a checkpoint with NO prompt from + // any source whenever a LateTranscriptWriter (Antigravity) committed while + // its transcript was still the empty placeholder SaveStep checkpointed — a + // routine mid-turn state for it — and took logCondensationPrompts down with + // it, so the breadcrumb was absent exactly when it was needed. The + // live-transcript sibling already resolves prompts this way. + promptSource := "shadow prompt.txt" + if file, fileErr := tree.File(metadataDir + "/" + paths.PromptFileName); fileErr == nil { + if content, contentErr := file.Contents(); contentErr == nil && content != "" { + data.Prompts = splitPromptContent(content) } } + // Filesystem fallback (written at turn start, covers mid-turn commits) + if len(data.Prompts) == 0 { + promptSource = "filesystem prompt.txt" + data.Prompts = readPromptsFromFilesystem(ctx, sessionID) + } + // Late-flush fallback: re-extract from the transcript bytes being + // checkpointed when prompt.txt is still empty (e.g. Antigravity writes + // the transcript after the Stop hook, so the TurnEnd backfill saw an + // empty file). + if len(data.Prompts) == 0 { + promptSource = "transcript" + data.Prompts = resolveCondensationPrompts(ctx, ag, data.Transcript, liveTranscriptPath, checkpointTranscriptStart) + } + logCondensationPrompts(ctx, sessionID, promptSource, len(data.Prompts), checkpointTranscriptStart) // Use tracked files from session state (not all files in tree) data.FilesTouched = filesTouched @@ -1489,14 +1567,39 @@ func (s *ManualCommitStrategy) extractSessionDataFromLiveTranscript(ctx context. return nil, fmt.Errorf("failed to read live transcript: %w", err) } + // An empty live transcript degrades for late-transcript agents but errors + // for the rest. A LateTranscriptWriter (e.g. agy) flushes its transcript + // AFTER the Stop hook, so a first-turn mid-turn commit legitimately + // condenses while the transcript is still an empty placeholder — and + // erroring happens after prepare-commit-msg already stamped the + // Entire-Checkpoint trailer, leaving the commit pointing at a checkpoint + // that never gets written. For every other agent an empty live transcript + // is a transient race (the file exists but the write hasn't landed), and + // erroring preserves the retry invariant: the failed condensation leaves + // session state untouched so the next commit re-condenses with the + // populated transcript. if len(liveData) == 0 { - return nil, errors.New("live transcript is empty") + if _, lateOK := agent.AsLateTranscriptWriter(ag); !lateOK { + return nil, errors.New("live transcript is empty") + } + logging.Warn(logging.WithComponent(ctx, "checkpoint"), + "live transcript is empty at condensation, degrading to files/prompt-only checkpoint", + slog.String("session_id", state.SessionID)) } fullTranscript := string(liveData) data.Transcript = liveData data.FullTranscriptLines = countTranscriptItems(state.AgentType, fullTranscript) + promptSource := "filesystem prompt.txt" data.Prompts = readPromptsFromFilesystem(ctx, state.SessionID) + // Late-flush fallback: re-extract from the transcript bytes being + // checkpointed when prompt.txt is still empty (e.g. Antigravity writes the + // transcript after the Stop hook). + if len(data.Prompts) == 0 { + promptSource = "transcript" + data.Prompts = resolveCondensationPrompts(ctx, ag, data.Transcript, transcriptPath, state.CheckpointTranscriptStart) + } + logCondensationPrompts(ctx, state.SessionID, promptSource, len(data.Prompts), state.CheckpointTranscriptStart) // Resolve files touched: prefers hook-populated state, falls back to transcript extraction data.FilesTouched = s.resolveFilesTouched(ctx, state) @@ -1516,6 +1619,145 @@ func (s *ManualCommitStrategy) extractSessionDataFromLiveTranscript(ctx context. return data, nil } +// resolvePendingTranscriptOffset completes a turn-end advance of +// CheckpointTranscriptStart that HandleTurnEnd could not perform because a +// late-transcript agent (agent.LateTranscriptWriter) had not flushed its +// transcript by the Stop hook. TranscriptOffsetPending guarantees everything +// the flush eventually wrote is already-condensed content, and mid-turn +// (ACTIVE) such an agent's file cannot yet contain the current turn — so the +// flushed file end IS the correct start of the current checkpoint scope. +// Without this, prompt extraction and the scoped transcript for the current +// checkpoint would start inside the previous turn, attributing the previous +// turn's prompt to this checkpoint. +// +// The flag is consumed by every outcome EXCEPT the one it exists for: an +// ACTIVE session whose transcript still has not flushed, where the advance +// cannot be computed yet (GetTranscriptPosition fails, or returns a position +// no later than the stale offset). Clearing it there discarded the deferral on +// a second commit made inside the same unflushed window and reproduced the +// prompt shift the flag was added to prevent. Letting it survive is safe: +// condensation rewrites CheckpointTranscriptStart to the transcript end on +// success, so a flag that outlives a successful pass finds pos <= start on the +// next one and no-ops. Outside ACTIVE phase the file may already include the +// current turn's (uncondensed) content, so the advance is skipped and the flag +// IS consumed — the scope is bloated by the condensed tail this once, then +// self-heals. The capability checks consume it too: they can never come true +// for this agent, so a surviving flag would be re-read forever. +func resolvePendingTranscriptOffset(ctx context.Context, ag agent.Agent, state *SessionState) { + if !state.TranscriptOffsetPending { + return + } + if !state.Phase.IsActive() { + state.TranscriptOffsetPending = false + // Logged at the same level as the advance below, so the skip and the + // advance are equally visible: this is where the checkpoint scope + // silently keeps the previous turn's already-condensed tail, which + // then shows up as duplicated content in a summary. + logging.Info(ctx, "skipping deferred turn-end offset advance outside ACTIVE phase; this checkpoint's scope keeps the condensed tail", + slog.String("session_id", state.SessionID), + slog.String("phase", string(state.Phase)), + slog.Int("offset", state.CheckpointTranscriptStart), + ) + return + } + // Permanent for this agent: a flag left set would be re-read on every + // condensation for the life of the session without ever resolving. + if _, ok := agent.AsLateTranscriptWriter(ag); !ok { + state.TranscriptOffsetPending = false + return + } + analyzer, ok := agent.AsTranscriptAnalyzer(ag) + if !ok { + state.TranscriptOffsetPending = false + return + } + // From here the flag SURVIVES a failure: the transcript simply has not + // flushed yet, which is the state the deferral was recorded for, so the + // next condensation must get the same chance rather than inherit a stale + // offset pointing inside the previous, already-condensed turn. + transcriptPath, err := resolveTranscriptPath(state) + if err != nil { + return + } + pos, posErr := analyzer.GetTranscriptPosition(transcriptPath) + if posErr != nil || pos <= state.CheckpointTranscriptStart { + logging.Info(ctx, "deferred turn-end offset advance still unresolved (transcript not flushed); keeping it pending", + slog.String("session_id", state.SessionID), + slog.Int("offset", state.CheckpointTranscriptStart), + ) + return + } + logging.Info(ctx, "completing deferred turn-end offset advance before condensation", + slog.String("session_id", state.SessionID), + slog.Int("old_offset", state.CheckpointTranscriptStart), + slog.Int("new_offset", pos), + ) + state.CheckpointTranscriptStart = pos + state.TranscriptOffsetPending = false +} + +// resolveCondensationPrompts is the last rung of the prompt ladder. It +// extracts from the transcript BYTES condensation is about to store, through +// agent.TranscriptPromptExtractor, so the recorded prompts always describe the +// stored transcript — including when that transcript came from the +// shadow-branch copy because the live path could not be read, which is exactly +// when a re-read of the path would find nothing and record no prompt while the +// checkpoint carried the full conversation. Agents without the bytes extractor +// keep the path-based fallback. +func resolveCondensationPrompts(ctx context.Context, ag agent.Agent, transcript []byte, transcriptPath string, offset int) []string { + if len(transcript) > 0 { + if extractor, ok := agent.AsTranscriptPromptExtractor(ag); ok { + prompts, err := extractor.ExtractPromptsFromTranscript(transcript, offset) + if err != nil { + logging.Warn(ctx, "condensation prompt extraction from transcript bytes failed", + slog.String("error", err.Error())) + } else { + return prompts + } + } + } + return resolvePromptsFromLateFlushedTranscript(ctx, ag, transcriptPath, offset) +} + +// logCondensationPrompts records which rung of the prompt ladder supplied the +// checkpoint's prompts. Every rung fails silently by design (a missing file is +// "no prompts"), which made a checkpoint that carried a full transcript and no +// prompt undiagnosable after the fact; this is the breadcrumb that was missing. +func logCondensationPrompts(ctx context.Context, sessionID, source string, count, offset int) { + if count == 0 { + source = "none" + } + logging.Debug(logging.WithComponent(ctx, "checkpoint"), "condensation prompts resolved", + slog.String("session_id", sessionID), + slog.String("source", source), + slog.Int("count", count), + slog.Int("transcript_offset", offset), + ) +} + +// resolvePromptsFromLateFlushedTranscript re-extracts user prompts directly +// from a populated transcript at condensation time. Agents like Antigravity +// write their transcript AFTER the Stop hook, so the TurnEnd prompt backfill +// (lifecycle.go) saw an empty transcript and prompt.txt is empty. By +// condensation the live transcript is populated. General — any PromptExtractor +// benefits; callers only invoke this when prompts are otherwise empty. +func resolvePromptsFromLateFlushedTranscript(ctx context.Context, ag agent.Agent, transcriptPath string, offset int) []string { + if transcriptPath == "" { + return nil + } + extractor, ok := agent.AsPromptExtractor(ag) + if !ok { + return nil + } + prompts, err := extractor.ExtractPrompts(transcriptPath, offset) + if err != nil { + logging.Warn(ctx, "condensation prompt extraction failed", + slog.String("error", err.Error())) + return nil + } + return prompts +} + func calculateLiveTranscriptTokenUsage( ctx context.Context, ag agent.Agent, @@ -1595,6 +1837,18 @@ func countTranscriptItems(agentType types.AgentType, content string) int { return 0 } + // Late-transcript agents (e.g. Antigravity) own their offset metric: the + // value counted here lands in CheckpointTranscriptStart and is later fed + // back to the agent's own readers (ExtractPrompts, GetTranscriptPosition) + // as an offset, so writer and readers must agree on the counting rule. + // Delegating via the capability keeps the metric in one place instead of + // hand-syncing a copy across the package boundary. + if ag, agErr := agent.GetByAgentType(agentType); agErr == nil { + if lw, ok := agent.AsLateTranscriptWriter(ag); ok { + return lw.CountTranscriptPosition([]byte(content)) + } + } + // Claude Code and other JSONL-based agents allLines := strings.Split(content, "\n") // Trim trailing empty lines (from final \n in JSONL) diff --git a/cmd/entire/cli/strategy/manual_commit_condensation_test.go b/cmd/entire/cli/strategy/manual_commit_condensation_test.go index 167816bbd8..bfcc687689 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation_test.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation_test.go @@ -27,6 +27,7 @@ import ( "github.com/go-git/go-git/v6/plumbing" // Register agents so GetByAgentType works in tests. + _ "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/copilotcli" _ "github.com/entireio/cli/cmd/entire/cli/agent/cursor" @@ -688,6 +689,205 @@ func TestCondenseSession_TagsCheckpointSummaryWithHasInvestigation(t *testing.T) require.Equal(t, "Why is checkout flaky?", meta.InvestigateTopic, "per-session InvestigateTopic") } +// TestCondenseSession_OutOfBandTokenFallback verifies that for an agent without +// transcript-embedded token data (Antigravity is not a TokenCalculator), a +// populated SessionState.TokenUsage flows through to the per-session +// CommittedMetadata.token_usage on the metadata branch. This is the out-of-band +// fallback: agy accumulates per-turn deltas into SessionState.TokenUsage at +// SaveStep time, and the transcript recompute yields nil, so without the +// fallback the UI would show no token counts. +// +// Tests in this file use t.Chdir for CWD-based git resolution, so this +// cannot be a parallel test. +func TestCondenseSession_OutOfBandTokenFallback(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := gitrepo.OpenPath(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + sessionID := "2026-06-03-antigravity-tokens" + + metadataDir := ".entire/metadata/" + sessionID + metadataDirAbs := filepath.Join(dir, metadataDir) + require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755)) + + // Antigravity transcripts carry no token usage, so the condensation + // recompute yields nil — exactly the case the fallback handles. + transcript := `{"type":"human","message":{"content":"add tokens"}} +{"type":"assistant","message":{"content":"Done."}} +` + require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644)) + + trackedFile := filepath.Join(dir, "test.txt") + require.NoError(t, os.WriteFile(trackedFile, []byte("agent-modified content"), 0o644)) + + require.NoError(t, s.SaveStep(context.Background(), StepContext{ + SessionID: sessionID, + AgentType: agent.AgentTypeAntigravity, + ModifiedFiles: []string{"test.txt"}, + MetadataDir: metadataDir, + CommitMessage: "Antigravity checkpoint 1", + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + + state, err := s.loadSessionState(context.Background(), sessionID) + require.NoError(t, err) + require.Equal(t, agent.AgentTypeAntigravity, state.AgentType, "session must be tagged as Antigravity") + + // Simulate the lifecycle accumulating an out-of-band token delta: + // SaveStep accumulates StepContext.TokenUsage into BOTH the + // checkpoint-scoped CheckpointTokenUsage (reset at every condensation) + // and the session-cumulative TokenUsage (never reset). The checkpoint + // metadata must take the checkpoint-scoped value. Make the cumulative + // total deliberately LARGER (as after an earlier condensed turn) so this + // test fails if condensation ever inherits the cumulative total again — + // that bug double-counted earlier turns on every checkpoint after the + // first. + state.CheckpointTokenUsage = &agent.TokenUsage{ + InputTokens: 3500, + OutputTokens: 300, + CacheCreationTokens: 50, + CacheReadTokens: 3400, + APICallCount: 2, + } + state.TokenUsage = &agent.TokenUsage{ + InputTokens: 9999, + OutputTokens: 888, + CacheCreationTokens: 77, + CacheReadTokens: 6666, + APICallCount: 5, + } + require.NoError(t, SaveSessionState(context.Background(), state)) + + checkpointID := id.MustCheckpointID("ddee00112233") + result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil) + require.NoError(t, err) + require.False(t, result.Skipped, "condensation must not skip when files are touched") + + ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + require.NoError(t, err) + commit, err := repo.CommitObject(ref.Hash()) + require.NoError(t, err) + tree, err := commit.Tree() + require.NoError(t, err) + + checkpointTree, err := tree.Tree(checkpointID.Path()) + require.NoError(t, err) + + // Per-session metadata must round-trip the out-of-band token usage. + sessionMeta, err := checkpointTree.File(checkpointID.Path() + "/0/" + paths.MetadataFileName) + if err != nil { + subtree, subErr := checkpointTree.Tree("0") + require.NoError(t, subErr) + sessionMeta, err = subtree.File(paths.MetadataFileName) + require.NoError(t, err) + } + sessionBytes, err := sessionMeta.Contents() + require.NoError(t, err) + var meta checkpoint.Metadata + require.NoError(t, json.Unmarshal([]byte(sessionBytes), &meta)) + + require.NotNil(t, meta.TokenUsage, "per-session token_usage must be populated from the checkpoint-scoped accumulator") + require.Equal(t, 3500, meta.TokenUsage.InputTokens, "InputTokens must be the checkpoint-scoped delta, not the session-cumulative total") + require.Equal(t, 300, meta.TokenUsage.OutputTokens, "OutputTokens") + require.Equal(t, 50, meta.TokenUsage.CacheCreationTokens, "CacheCreationTokens") + require.Equal(t, 3400, meta.TokenUsage.CacheReadTokens, "CacheReadTokens") + require.Equal(t, 2, meta.TokenUsage.APICallCount, "APICallCount") +} + +// TestCondenseSession_NonOOBAgentDoesNotInheritStateTokens is the negative +// branch of the token resolution: NO agent may inherit the session-cumulative +// SessionState.TokenUsage into per-checkpoint metadata (it is never reset at +// condensation, so it double-counts earlier turns). Checkpoint metadata comes +// only from the transcript recompute or the checkpoint-scoped +// state.CheckpointTokenUsage. Cursor with a populated state total and a nil +// recompute must therefore produce empty checkpoint token_usage. +// +// Tests in this file use t.Chdir for CWD-based git resolution, so this +// cannot be a parallel test. +func TestCondenseSession_NonOOBAgentDoesNotInheritStateTokens(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := gitrepo.OpenPath(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + sessionID := "2026-06-03-cursor-tokens" + + metadataDir := ".entire/metadata/" + sessionID + metadataDirAbs := filepath.Join(dir, metadataDir) + require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755)) + + // Token-less transcript: the condensation recompute yields nil TokenUsage, + // mirroring the positive test so the only behavioral difference is the + // agent's OutOfBandTokenSource capability. + transcript := `{"type":"human","message":{"content":"add tokens"}} +{"type":"assistant","message":{"content":"Done."}} +` + require.NoError(t, os.WriteFile(filepath.Join(metadataDirAbs, paths.TranscriptFileName), []byte(transcript), 0o644)) + + trackedFile := filepath.Join(dir, "test.txt") + require.NoError(t, os.WriteFile(trackedFile, []byte("agent-modified content"), 0o644)) + + require.NoError(t, s.SaveStep(context.Background(), StepContext{ + SessionID: sessionID, + AgentType: agent.AgentTypeCursor, + ModifiedFiles: []string{"test.txt"}, + MetadataDir: metadataDir, + CommitMessage: "Cursor checkpoint 1", + AuthorName: "Test", + AuthorEmail: "test@test.com", + })) + + state, err := s.loadSessionState(context.Background(), sessionID) + require.NoError(t, err) + require.Equal(t, agent.AgentTypeCursor, state.AgentType, "session must be tagged as Cursor") + + // Populate SessionState.TokenUsage anyway. A non-OOB agent must NOT inherit + // this — the fallback gate excludes it. + state.TokenUsage = &agent.TokenUsage{ + InputTokens: 3500, + OutputTokens: 300, + CacheCreationTokens: 50, + CacheReadTokens: 3400, + APICallCount: 2, + } + require.NoError(t, SaveSessionState(context.Background(), state)) + + checkpointID := id.MustCheckpointID("ddee44556677") + result, err := s.CondenseSession(context.Background(), repo, checkpointID, state, nil) + require.NoError(t, err) + require.False(t, result.Skipped, "condensation must not skip when files are touched") + + ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + require.NoError(t, err) + commit, err := repo.CommitObject(ref.Hash()) + require.NoError(t, err) + tree, err := commit.Tree() + require.NoError(t, err) + + checkpointTree, err := tree.Tree(checkpointID.Path()) + require.NoError(t, err) + + sessionMeta, err := checkpointTree.File(checkpointID.Path() + "/0/" + paths.MetadataFileName) + if err != nil { + subtree, subErr := checkpointTree.Tree("0") + require.NoError(t, subErr) + sessionMeta, err = subtree.File(paths.MetadataFileName) + require.NoError(t, err) + } + sessionBytes, err := sessionMeta.Contents() + require.NoError(t, err) + var meta checkpoint.Metadata + require.NoError(t, json.Unmarshal([]byte(sessionBytes), &meta)) + + require.Nil(t, meta.TokenUsage, "non-OOB agent must NOT inherit SessionState.TokenUsage; per-session token_usage must be nil") +} + func setupEndedSessionWithoutFiles(t *testing.T, s *ManualCommitStrategy, repo *git.Repository, dir, sessionID string) *SessionState { t.Helper() setupSessionWithCheckpoint(t, s, repo, dir, sessionID) diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 6a83aea11d..051e3c7fab 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "io" + "io/fs" "log/slog" "os" "os/exec" @@ -1515,9 +1516,19 @@ func (s *ManualCommitStrategy) postCommitProcessSessionLocked( // State is saved by the outer MutateSessionState in PostCommit. - // Only preserve shadow branch for active sessions that were NOT condensed. - // Condensed sessions already have their data on entire/checkpoints/v1. - if state.Phase.IsActive() && !handler.condensed { + // Only preserve the shadow branch for active sessions that were NOT + // condensed AND still have content on it — their uncondensed checkpoints + // are the only copy of that work. An active session with nothing on the + // branch has nothing to lose (SaveStep recreates shadow branches on + // demand), so it must not pin the branch. Observed with Antigravity: + // a subagent runs as its own conversation and does all the work, while + // the parent conversation's final fullyIdle Stop never arrives in + // headless mode — leaving a "ghost" session that is ACTIVE forever with + // zero files and zero steps, which would otherwise preserve the branch + // indefinitely. Content is judged the same way condensation judges it + // (sessionHasShadowContent): a session whose first checkpoint captured a + // transcript but no file edits yet still has a commit to lose. + if state.Phase.IsActive() && !handler.condensed && sessionHasShadowContent(handler.filesTouchedBefore, state) { uncondensedActiveOnBranch[shadowBranchName] = true } @@ -2020,7 +2031,23 @@ func (s *ManualCommitStrategy) sessionHasNewContent(ctx context.Context, repo *g func (s *ManualCommitStrategy) sessionHasNewContentFromLiveTranscript(ctx context.Context, state *SessionState, stagedFiles []string) (bool, error) { logCtx := logging.WithComponent(ctx, "checkpoint") - if !s.hasNewTranscriptWork(ctx, state) { + // Hook-captured files are evidence of new work on their own: FilesTouched + // is cleared by condensation, so anything in it is uncondensed. Transcript + // growth is only consulted when the hooks recorded nothing, because for a + // late-transcript writer the file is still empty mid-turn — before agy's + // first Stop, position and CheckpointTranscriptStart are both zero — and + // letting that veto the hook-captured edits dropped the trailer from a + // user's mid-turn commit made from a terminal (the no-TTY fast path never + // reaches this function). + // + // Scoped to LateTranscriptWriter, because that reasoning is: only such an + // agent has an empty transcript mid-turn. For a streaming-transcript agent + // the growth check is meaningful evidence and stays a precondition, as it + // was before Antigravity — an agent added to the registry must not change + // the commit path of the agents already in it. + ag, _ := agent.GetByAgentType(state.AgentType) //nolint:errcheck // ag may be nil for unknown agent types; AsLateTranscriptWriter handles nil + _, lateWriter := agent.AsLateTranscriptWriter(ag) + if (!lateWriter || len(state.FilesTouched) == 0) && !s.hasNewTranscriptWork(ctx, state) { return false, nil } @@ -2205,7 +2232,7 @@ func (s *ManualCommitStrategy) extractModifiedFilesFromLiveTranscript(ctx contex } } } else { - files, _, err := analyzer.ExtractModifiedFilesFromOffset(state.TranscriptPath, offset) + files, _, err := analyzer.ExtractModifiedFilesFromOffset(logCtx, state.TranscriptPath, offset) if err != nil { logging.Debug(logCtx, "extractModifiedFilesFromLiveTranscript: main transcript extraction failed", slog.String("transcript_path", state.TranscriptPath), @@ -2328,14 +2355,7 @@ func (s *ManualCommitStrategy) tryAgentCommitFastPath(ctx context.Context, commi continue } eligibleSessions++ - // Skip sessions that have no condensable content: no transcript path, - // no tracked files, no SaveStep checkpoints, and no task records. These - // would produce a Skipped result in CondenseSession, leaving the - // Entire-Checkpoint trailer pointing to nothing on the metadata branch. - // NOTE: conservative approximation of the skip gate in CondenseSession - // (which checks extracted data, not raw state). Keep aligned. - if state.TranscriptPath == "" && len(state.FilesTouched) == 0 && - state.StepCount == 0 && !state.HasTaskContent() { + if sessionLacksCondensableContent(state) { emptyEligibleSessions++ logging.Debug(logCtx, "prepare-commit-msg: fast path skipping empty session", slog.String("session_id", state.SessionID), @@ -2369,6 +2389,84 @@ func (s *ManualCommitStrategy) tryAgentCommitFastPath(ctx context.Context, commi return false } +// sessionHasShadowContent reports whether a session has checkpoints on its +// shadow branch that a later condensation still needs: tracked files, at least +// one written step, or task checkpoints. StepCount only counts checkpoint +// commits that were actually written (a dedup-skipped step returns before the +// increment) and is reset to zero when the session is condensed, so a non-zero +// value means an uncondensed commit exists even when no file has been edited +// yet — a read-only first turn, or a question answered without touching the +// tree. filesTouched is passed separately because the post-commit handler +// judges the snapshot it took before mutating state. +func sessionHasShadowContent(filesTouched []string, state *SessionState) bool { + return len(filesTouched) > 0 || state.StepCount > 0 || state.HasTaskContent() +} + +// sessionLacksCondensableContent reports whether an ACTIVE session has nothing +// CondenseSession could turn into a checkpoint: no tracked files, no shadow +// branch data (StepCount == 0), no task records, and no transcript content. Stamping a trailer +// for such a session would leave the commit permanently referencing a +// checkpoint the condensation skip gate never writes (dangling trailer). +// +// For most agents a non-empty TranscriptPath implies content — their +// transcripts stream during the turn. A LateTranscriptWriter (e.g. agy) +// writes its transcript file only AFTER the Stop hook, so mid-turn the +// recorded path routinely points at a missing or still-empty file; only an +// on-disk stat tells the truth. Other agents keep the cheap path-only check: +// for them an empty transcript file at commit time is a transient write race, +// and condensation errors-and-retries rather than skipping, so the trailer +// heals on the next commit. +// +// NOTE: conservative approximation of the skip gate in CondenseSession (which +// checks extracted data, not raw state). Keep aligned. +func sessionLacksCondensableContent(state *SessionState) bool { + if sessionHasShadowContent(state.FilesTouched, state) { + return false + } + if state.TranscriptPath == "" { + return true + } + if ag, err := agent.GetByAgentType(state.AgentType); err == nil { + if _, lateOK := agent.AsLateTranscriptWriter(ag); lateOK { + // IsRegular, not merely "not a symlink": a directory or any other + // non-file at the path has a Size() too (a directory's is its + // entry-table size), and the readers downstream cannot condense + // it, so anything that is not a regular file counts as no content. + info, statErr := lstatLateTranscript(ag, state) + if statErr != nil { + // An absent file, or a path the store refuses (a symlink at + // any component, a non-regular leaf), is "no content": the + // full path would refuse it too. Any other stat failure + // (EACCES, ENOTDIR, an I/O error) says nothing about the + // transcript, and classing it as empty would drop the session + // from this commit's trailer with no visible error; the full + // path reads the file and reports what is wrong with it. + return errors.Is(statErr, fs.ErrNotExist) || + errors.Is(statErr, osroot.ErrSymlinkedPath) || + errors.Is(statErr, osroot.ErrNotRegularFile) + } + return !info.Mode().IsRegular() || info.Size() == 0 + } + } + return false +} + +// lstatLateTranscript stats a late-transcript agent's transcript path the way +// its own reads are contained: as a name inside the agent's SessionStore when +// the path lies in the agent's session directory (a symlink at any component +// is refused there, not followed), and otherwise with a plain Lstat — never a +// Stat — so a linked leaf is still reported as a link and counts as no content +// (filesystem-safety.md). The path is one the hook recorded into session state, +// and the fallback is the documented read-side gap, not a parent-derived root. +func lstatLateTranscript(ag agent.Agent, state *SessionState) (os.FileInfo, error) { + if store, err := agent.OpenSessionStore(ag, state.WorktreePath); err == nil { + if name, nameErr := store.Name(state.TranscriptPath); nameErr == nil { + return store.Lstat(name) //nolint:wrapcheck // preserved for the caller's "no content" classification + } + } + return os.Lstat(state.TranscriptPath) //nolint:wrapcheck // same classification; see doc comment +} + // addTrailerForAgentCommit handles the fast path for an eligible agent session. // The caller has already gated on no TTY or commit_linking="always" and selected // an ACTIVE session or an IDLE session with a fresh task record. @@ -3043,25 +3141,61 @@ func (s *ManualCommitStrategy) HandleTurnEnd(ctx context.Context, state *Session // intentionally resets CheckpointTranscriptStart to 0 so the next checkpoint // remains self-contained with the full transcript. if hadMidTurnCommits && state.TranscriptPath != "" && len(state.FilesTouched) == 0 { - transcriptPath, resolveErr := resolveTranscriptPath(state) - if resolveErr == nil { - if ag, agErr := agent.GetByAgentType(state.AgentType); agErr == nil { - if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok { - if pos, posErr := analyzer.GetTranscriptPosition(transcriptPath); posErr == nil && pos > state.CheckpointTranscriptStart { - logging.Debug(logging.WithComponent(ctx, "hooks"), - "advancing CheckpointTranscriptStart to turn end after mid-turn commit", - slog.String("session_id", state.SessionID), - slog.Int("old_offset", state.CheckpointTranscriptStart), - slog.Int("new_offset", pos), - ) - state.CheckpointTranscriptStart = pos - } - } + advanceCheckpointTranscriptStartToTurnEnd(ctx, state) + } + + return nil +} + +// advanceCheckpointTranscriptStartToTurnEnd moves CheckpointTranscriptStart to +// the current transcript end after a fully-condensed turn (see HandleTurnEnd's +// call-site comment). When the advance cannot run for a late-transcript agent +// — agy flushes its transcript only after Stop, so this read routinely races +// the flush and sees the pre-turn state — the failure is recorded in +// TranscriptOffsetPending so the next mid-turn condensation can retry against +// the by-then-flushed file (resolvePendingTranscriptOffset). Without the +// retry, a lost race leaves the offset inside the previous turn, so the next +// checkpoint's prompt extraction picks up the previous turn's prompt and its +// scoped transcript includes an already-condensed tail. Streaming-transcript +// agents never set the flag: for them a non-growing transcript legitimately +// means "no new content". +func advanceCheckpointTranscriptStartToTurnEnd(ctx context.Context, state *SessionState) { + logCtx := logging.WithComponent(ctx, "hooks") + ag, agErr := agent.GetByAgentType(state.AgentType) + if agErr != nil { + return + } + _, isLate := agent.AsLateTranscriptWriter(ag) + + advanced := false + if transcriptPath, resolveErr := resolveTranscriptPath(state); resolveErr == nil { + if analyzer, ok := agent.AsTranscriptAnalyzer(ag); ok { + if pos, posErr := analyzer.GetTranscriptPosition(transcriptPath); posErr == nil && pos > state.CheckpointTranscriptStart { + logging.Debug(logCtx, + "advancing CheckpointTranscriptStart to turn end after mid-turn commit", + slog.String("session_id", state.SessionID), + slog.Int("old_offset", state.CheckpointTranscriptStart), + slog.Int("new_offset", pos), + ) + state.CheckpointTranscriptStart = pos + advanced = true } } } - return nil + if !isLate { + return + } + if advanced { + state.TranscriptOffsetPending = false + return + } + state.TranscriptOffsetPending = true + logging.Debug(logCtx, + "turn-end offset advance lost the transcript flush race, deferring to next condensation", + slog.String("session_id", state.SessionID), + slog.Int("offset", state.CheckpointTranscriptStart), + ) } // precomputeTranscriptBlobsForFinalize chunks + zlib-compresses the redacted @@ -3169,8 +3303,26 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s return 1 // Count as error - all checkpoints will be skipped } + ag, _ := agent.GetByAgentType(state.AgentType) //nolint:errcheck // ag may be nil for unknown agent types; ExtractSkillEvents handles nil + fullTranscript, err := agent.ReadTranscriptFile(transcriptPath) if err != nil || len(fullTranscript) == 0 { + // A late-transcript agent (agy) writes its transcript AFTER the Stop + // hook, so an empty file here is the placeholder PrepareTranscript + // materialised when the flush lost the race — a normal, transient + // state, not a lost transcript. Keep TurnCheckpointIDs so a later + // HandleTurnEnd in this turn finalizes with the flushed content, the + // same deferral the degraded-scanner path below uses; nilling them + // would abandon the backfill for every mid-turn checkpoint of the turn + // on the first Stop that beat the flush. + if _, late := agent.AsLateTranscriptWriter(ag); late && err == nil { + logging.Info(logCtx, "finalize: late-transcript agent has not flushed yet, deferring", + slog.String("session_id", state.SessionID), + slog.String("transcript_path", state.TranscriptPath), + slog.Int("checkpoint_count", len(state.TurnCheckpointIDs)), + ) + return 1 // Reported, not abandoned: TurnCheckpointIDs survive for the retry + } msg := "finalize: empty transcript, skipping" if err != nil { msg = "finalize: failed to read transcript, skipping" @@ -3200,7 +3352,6 @@ func (s *ManualCommitStrategy) finalizeAllTurnCheckpoints(ctx context.Context, s prompts = readPromptsFromFilesystem(ctx, state.SessionID) } - ag, _ := agent.GetByAgentType(state.AgentType) //nolint:errcheck // ag may be nil for unknown agent types; ExtractSkillEvents handles nil // Persist newly extracted events into state (the caller's MutateSessionState // saves them); telemetry for them is emitted by the lifecycle turn-end // handler, which snapshots state.SkillEvents growth around HandleTurnEnd. @@ -3441,6 +3592,9 @@ func (s *ManualCommitStrategy) carryForwardToNewShadowBranch( state.StepCount = 1 state.CheckpointTranscriptStart = 0 state.CheckpointTranscriptSize = 0 + // Carry-forward deliberately restarts the offset at 0; a pending turn-end + // advance from before the carry-forward must not re-apply on top of it. + state.TranscriptOffsetPending = false state.LastCheckpointID = "" // NOTE: TurnCheckpointIDs is intentionally NOT cleared here. Those checkpoint // IDs from earlier in the turn still need finalization with the full transcript diff --git a/cmd/entire/cli/strategy/mid_turn_commit_test.go b/cmd/entire/cli/strategy/mid_turn_commit_test.go index bb4ec893c5..7d5734c437 100644 --- a/cmd/entire/cli/strategy/mid_turn_commit_test.go +++ b/cmd/entire/cli/strategy/mid_turn_commit_test.go @@ -98,6 +98,67 @@ func TestSessionHasNewContentFromLiveTranscript_NormalizesAbsolutePaths(t *testi } // A records-only session must read as having new content — the predicate the pending-task triggers rest on. +// TestSessionHasNewContentFromLiveTranscript_EmptyLateTranscriptTrustsHookFiles: +// a user commits from a terminal during agy's first turn, before any Stop. No +// shadow branch exists and the transcript is still an empty placeholder, so +// transcript growth reads as zero — but PreToolUse already recorded the edit. +// The hook-captured file must carry the decision; an empty transcript must not +// veto it. +func TestSessionHasNewContentFromLiveTranscript_EmptyLateTranscriptTrustsHookFiles(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + s := &ManualCommitStrategy{} + + docsDir := filepath.Join(dir, "docs") + require.NoError(t, os.MkdirAll(docsDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(docsDir, "blue.md"), []byte("Blue.\n"), 0o644)) + wt, err := repo.Worktree() + require.NoError(t, err) + _, err = wt.Add("docs/blue.md") + require.NoError(t, err) + + worktreePath, err := paths.WorktreeRoot(context.Background()) + require.NoError(t, err) + worktreeID, err := paths.GetWorktreeID(worktreePath) + require.NoError(t, err) + // agy's placeholder: present and empty. + transcriptPath := filepath.Join(dir, "conv", ".system_generated", "logs", "transcript_full.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o755)) + require.NoError(t, os.WriteFile(transcriptPath, nil, 0o600)) + + now := time.Now() + head, err := repo.Head() + require.NoError(t, err) + state := &SessionState{ + SessionID: "agy-first-turn", + BaseCommit: head.Hash().String(), + WorktreePath: worktreePath, + WorktreeID: worktreeID, + StartedAt: now, + Phase: session.PhaseActive, + LastInteractionTime: &now, + AgentType: agent.AgentTypeAntigravity, + TranscriptPath: transcriptPath, + FilesTouched: []string{"docs/blue.md"}, + CheckpointTranscriptStart: 0, + } + require.NoError(t, s.saveSessionState(context.Background(), state)) + + stagedFiles, err := getStagedFiles(context.Background()) + require.NoError(t, err) + hasNew, err := s.sessionHasNewContent(context.Background(), repo, state, contentCheckOpts{stagedFiles: stagedFiles}) + require.NoError(t, err) + assert.True(t, hasNew, "hook-captured files overlapping the staged files are new content even when the late transcript is still empty") + + // Without hook-captured files, an empty transcript is still no content. + state.FilesTouched = nil + hasNew, err = s.sessionHasNewContent(context.Background(), repo, state, contentCheckOpts{stagedFiles: stagedFiles}) + require.NoError(t, err) + assert.False(t, hasNew, "nothing captured and nothing written: no new content") +} + func TestSessionHasNewContent_RecordsOnlySession(t *testing.T) { dir := setupGitRepo(t) t.Chdir(dir) diff --git a/cmd/entire/cli/strategy/phase_postcommit_test.go b/cmd/entire/cli/strategy/phase_postcommit_test.go index 491bf4c382..69c07d738e 100644 --- a/cmd/entire/cli/strategy/phase_postcommit_test.go +++ b/cmd/entire/cli/strategy/phase_postcommit_test.go @@ -315,12 +315,16 @@ func TestPostCommit_ReadOnlyActiveSessionNotCondensed(t *testing.T) { assert.Equal(t, 0, idleState.StepCount, "IDLE session StepCount should be reset after condensation") - // Shadow branch should be preserved because the ACTIVE session was NOT condensed - // (it had no files touched, and totalSessionCount > 1 triggered the gate) + // The shadow branch must be DELETED: the read-only ACTIVE session has no + // files and no checkpoints on it (nothing to lose), and PostCommit rebases + // its BaseCommit to the new HEAD anyway — future work goes to a NEW branch + // name, so preserving the old branch would leak it permanently. (This + // exact leak was observed live with Antigravity's headless subagent flow, + // where the parent conversation lingers as a files-less ACTIVE ghost.) refName := plumbing.NewBranchReferenceName(shadowBranch) _, err = repo.Reference(refName, true) - assert.NoError(t, err, - "shadow branch should be preserved — uncondensed ACTIVE session still references it") + assert.Error(t, err, + "shadow branch must be deleted — the read-only ACTIVE session has nothing on it and was rebased to the new HEAD") } // TestPostCommit_CondensationFailure_PreservesShadowBranch verifies that when diff --git a/cmd/entire/cli/strategy/postcommit_ghost_session_test.go b/cmd/entire/cli/strategy/postcommit_ghost_session_test.go new file mode 100644 index 0000000000..e773099fad --- /dev/null +++ b/cmd/entire/cli/strategy/postcommit_ghost_session_test.go @@ -0,0 +1,140 @@ +package strategy + +import ( + "context" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPostCommit_GhostActiveSessionDoesNotPinShadowBranch reproduces the agy +// headless-subagent failure from live E2E (TestSubagentCommitFlow): agy runs a +// subagent as a SEPARATE conversation with its own hooks. The subagent's Stop +// arrives fullyIdle=true (session condenses normally at commit), but the +// parent conversation only ever sends fullyIdle=false Stops before the process +// exits — leaving a ghost session that is ACTIVE forever with no tracked files +// and no checkpoints. The shadow-branch cleanup preserved the branch for ANY +// active session, so the ghost pinned it indefinitely. +// +// An active session with nothing uncondensed to lose (no FilesTouched) must +// not pin the branch: SaveStep recreates shadow branches on demand, so nothing +// is lost if it does produce work later. +func TestPostCommit_GhostActiveSessionDoesNotPinShadowBranch(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + workerID := "test-ghost-worker" // the subagent conversation: did the work + ghostID := "test-ghost-parent" // the parent conversation: ghost-ACTIVE + + // Worker: checkpoint on the shadow branch, went IDLE at its Stop. + setupSessionWithCheckpoint(t, s, repo, dir, workerID) + worker, err := s.loadSessionState(context.Background(), workerID) + require.NoError(t, err) + now := time.Now() + worker.Phase = session.PhaseIdle + worker.LastInteractionTime = &now + require.NoError(t, s.saveSessionState(context.Background(), worker)) + shadowBranch := getShadowBranchNameForCommit(worker.BaseCommit, worker.WorktreeID) + + // Ghost parent: ACTIVE, recent, same base commit, NO files, NO checkpoints. + ghost := &SessionState{ + SessionID: ghostID, + AgentType: worker.AgentType, + BaseCommit: worker.BaseCommit, + WorktreeID: worker.WorktreeID, + WorktreePath: worker.WorktreePath, // PostCommit filters sessions by worktree path + Phase: session.PhaseActive, + StartedAt: now, + LastInteractionTime: &now, + } + require.NoError(t, s.saveSessionState(context.Background(), ghost)) + + // User commits the worker's file; PostCommit condenses the worker. + commitWithCheckpointTrailer(t, repo, dir, "aabb00112233") + require.NoError(t, s.PostCommit(context.Background())) + + // The worker condensed to the metadata branch... + _, err = repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true) + require.NoError(t, err, "worker session should have condensed") + + // ...and the ghost must NOT pin the shadow branch. + _, err = repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true) + assert.Error(t, err, + "shadow branch must be deleted: the only active session has no tracked files and no uncondensed content to lose") +} + +// TestPostCommit_ActiveSessionWithShadowContentStillPinsShadowBranch is the +// inverse guard: an ACTIVE session that still has content on the shadow branch +// must keep pinning it (its uncondensed checkpoints are still needed). Files +// are not the only such content — StepCount only counts checkpoint commits +// that were actually written and is reset on condensation, so a session whose +// first checkpoint captured a transcript before any edit (a read-only tool +// call, a question answered without touching the tree) has exactly one commit +// on the branch and nothing else to prove it by. +func TestPostCommit_ActiveSessionWithShadowContentStillPinsShadowBranch(t *testing.T) { + cases := []struct { + name string + mutate func(active *SessionState) + }{ + {name: "uncommitted tracked file", mutate: func(a *SessionState) { + a.FilesTouched = []string{"other-uncommitted.txt"} + }}, + {name: "written step with no files yet", mutate: func(a *SessionState) { + a.StepCount = 1 + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Not parallel: t.Chdir is process-global. + dir := setupGitRepo(t) + t.Chdir(dir) + + repo, err := git.PlainOpen(dir) + require.NoError(t, err) + + s := &ManualCommitStrategy{} + workerID := "test-pin-worker" + activeID := "test-pin-active" + + setupSessionWithCheckpoint(t, s, repo, dir, workerID) + worker, err := s.loadSessionState(context.Background(), workerID) + require.NoError(t, err) + now := time.Now() + worker.Phase = session.PhaseIdle + worker.LastInteractionTime = &now + require.NoError(t, s.saveSessionState(context.Background(), worker)) + shadowBranch := getShadowBranchNameForCommit(worker.BaseCommit, worker.WorktreeID) + + // A genuinely mid-turn session with its own uncondensed content. + active := &SessionState{ + SessionID: activeID, + AgentType: worker.AgentType, + BaseCommit: worker.BaseCommit, + WorktreeID: worker.WorktreeID, + WorktreePath: worker.WorktreePath, // PostCommit filters sessions by worktree path + Phase: session.PhaseActive, + StartedAt: now, + LastInteractionTime: &now, + } + tc.mutate(active) + require.NoError(t, s.saveSessionState(context.Background(), active)) + + commitWithCheckpointTrailer(t, repo, dir, "ccdd00112233") + require.NoError(t, s.PostCommit(context.Background())) + + _, err = repo.Reference(plumbing.NewBranchReferenceName(shadowBranch), true) + assert.NoError(t, err, + "shadow branch must be preserved for an active session with uncondensed shadow content") + }) + } +} diff --git a/cmd/entire/cli/strategy/session_condensable_content_test.go b/cmd/entire/cli/strategy/session_condensable_content_test.go new file mode 100644 index 0000000000..7913fb444f --- /dev/null +++ b/cmd/entire/cli/strategy/session_condensable_content_test.go @@ -0,0 +1,174 @@ +package strategy + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + _ "github.com/entireio/cli/cmd/entire/cli/agent/antigravity" // registers the late-transcript agent under test + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/stretchr/testify/require" +) + +// TestSessionLacksCondensableContent_LateTranscriptWriterPathShapes pins how +// the prepare-commit-msg fast path reads a late-transcript agent's transcript +// path. Only a non-empty REGULAR file counts as content: a directory at the +// path has a Size() too (its entry table), and a symlink is refused rather +// than followed, and neither can be condensed, so both must read as "nothing +// to condense" — otherwise the trailer is stamped and the checkpoint it names +// is never written. +func TestSessionLacksCondensableContent_LateTranscriptWriterPathShapes(t *testing.T) { + t.Parallel() + + newState := func(transcriptPath string) *SessionState { + return &SessionState{ + SessionID: "agy-conv", + AgentType: agent.AgentTypeAntigravity, + Phase: session.PhaseActive, + TranscriptPath: transcriptPath, + } + } + + t.Run("missing file lacks content", func(t *testing.T) { + t.Parallel() + require.True(t, sessionLacksCondensableContent(newState(filepath.Join(t.TempDir(), "absent.jsonl")))) + }) + + t.Run("empty file lacks content", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "empty.jsonl") + require.NoError(t, os.WriteFile(path, nil, 0o600)) + require.True(t, sessionLacksCondensableContent(newState(path))) + }) + + t.Run("non-empty regular file has content", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "transcript_full.jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"step_index":0}`+"\n"), 0o600)) + require.False(t, sessionLacksCondensableContent(newState(path))) + }) + + t.Run("directory at the path lacks content", func(t *testing.T) { + t.Parallel() + dir := filepath.Join(t.TempDir(), "transcript_full.jsonl") + require.NoError(t, os.MkdirAll(filepath.Join(dir, "child"), 0o750)) + info, err := os.Lstat(dir) + require.NoError(t, err) + require.True(t, info.IsDir()) + require.True(t, sessionLacksCondensableContent(newState(dir)), + "a directory is not a transcript, whatever Size() reports for it") + }) + + t.Run("stat failure other than not-exist counts as content", func(t *testing.T) { + t.Parallel() + // A regular file where a directory is expected: Lstat fails with + // ENOTDIR, which says nothing about the transcript. Only an absent + // file is "no content"; anything else must not drop the session from + // the commit's trailer on the fast path. + notADir := filepath.Join(t.TempDir(), "file") + require.NoError(t, os.WriteFile(notADir, []byte("x"), 0o600)) + path := filepath.Join(notADir, "transcript_full.jsonl") + _, err := os.Lstat(path) + require.Error(t, err) + require.False(t, sessionLacksCondensableContent(newState(path)), + "an unclassifiable stat error must fail toward condensing, not toward silently skipping") + }) + t.Run("symlinked transcript lacks content", func(t *testing.T) { + t.Parallel() + target := filepath.Join(t.TempDir(), "real.jsonl") + require.NoError(t, os.WriteFile(target, []byte(`{"step_index":0}`+"\n"), 0o600)) + link := filepath.Join(t.TempDir(), "transcript_full.jsonl") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink not supported: %v", err) + } + require.True(t, sessionLacksCondensableContent(newState(link)), + "a symlink is refused, not followed to its target's size") + }) +} + +// TestFinalizeAllTurnCheckpoints_LateTranscriptNotFlushedDefers pins the +// late-transcript deferral: agy writes its transcript AFTER the Stop hook, so +// the file HandleTurnEnd finds is often still the empty placeholder +// PrepareTranscript materialised. That is a transient state, not a lost +// transcript, and the mid-turn checkpoints' IDs must survive it so a later +// HandleTurnEnd finalizes them with the flushed content — the same deferral +// the degraded-scanner path uses. Nilling them abandoned the backfill for the +// whole turn on the first Stop that beat the flush. +func TestFinalizeAllTurnCheckpoints_LateTranscriptNotFlushedDefers(t *testing.T) { + t.Parallel() + + placeholder := filepath.Join(t.TempDir(), "transcript_full.jsonl") + require.NoError(t, os.WriteFile(placeholder, nil, 0o600)) + + state := &SessionState{ + SessionID: "agy-mid-turn", + AgentType: agent.AgentTypeAntigravity, + Phase: session.PhaseActive, + TranscriptPath: placeholder, + TurnCheckpointIDs: []string{"01ARZ3NDEKTSV4RRFFQ69G5FAV"}, + } + + errCount := NewManualCommitStrategy().finalizeAllTurnCheckpoints(context.Background(), state) + require.Equal(t, 1, errCount, "a deferred finalize is still reported to the best-effort caller") + require.Equal(t, []string{"01ARZ3NDEKTSV4RRFFQ69G5FAV"}, state.TurnCheckpointIDs, + "TurnCheckpointIDs must survive an unflushed late transcript so a later turn end retries") +} + +// A non-late agent's empty transcript is still the terminal condition it was: +// there is no later flush to wait for, so the IDs are cleared as before. +func TestFinalizeAllTurnCheckpoints_EmptyTranscriptClearsForOtherAgents(t *testing.T) { + t.Parallel() + + empty := filepath.Join(t.TempDir(), "transcript.jsonl") + require.NoError(t, os.WriteFile(empty, nil, 0o600)) + + state := &SessionState{ + SessionID: "claude-mid-turn", + AgentType: agent.AgentTypeClaudeCode, + Phase: session.PhaseActive, + TranscriptPath: empty, + TurnCheckpointIDs: []string{"01ARZ3NDEKTSV4RRFFQ69G5FAV"}, + } + + errCount := NewManualCommitStrategy().finalizeAllTurnCheckpoints(context.Background(), state) + require.Equal(t, 1, errCount) + require.Empty(t, state.TurnCheckpointIDs) +} + +// TestSessionLacksCondensableContent_StatsThroughTheSessionStore: when the +// recorded transcript path lies inside the agent's session directory, the +// fast-path stat goes through the agent's SessionStore, so a symlink at any +// component is refused there rather than followed, and the answer is "no +// content". Uses t.Setenv to pin agy's brain directory, so it is not parallel. +func TestSessionLacksCondensableContent_StatsThroughTheSessionStore(t *testing.T) { + brain := filepath.Join(t.TempDir(), "brain") + t.Setenv("ENTIRE_TEST_ANTIGRAVITY_BRAIN_DIR", brain) + + elsewhere := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(elsewhere, "transcript_full.jsonl"), []byte(`{"step_index":0}`+"\n"), 0o600)) + // The conversation directory itself is a link out of the brain: a store + // stat refuses the parent component, a plain Lstat of the leaf would not. + require.NoError(t, os.MkdirAll(brain, 0o750)) + if err := os.Symlink(elsewhere, filepath.Join(brain, "conv")); err != nil { + t.Skipf("symlink not supported: %v", err) + } + linked := filepath.Join(brain, "conv", "transcript_full.jsonl") + + state := &SessionState{ + SessionID: "agy-conv", + AgentType: agent.AgentTypeAntigravity, + Phase: session.PhaseActive, + TranscriptPath: linked, + } + require.True(t, sessionLacksCondensableContent(state), + "a transcript reached through a symlinked component inside the session directory must read as no content") + + // The same content on a real path inside the brain is content. + regular := filepath.Join(brain, "conv2", "transcript_full.jsonl") + require.NoError(t, os.MkdirAll(filepath.Dir(regular), 0o750)) + require.NoError(t, os.WriteFile(regular, []byte(`{"step_index":0}`+"\n"), 0o600)) + state.TranscriptPath = regular + require.False(t, sessionLacksCondensableContent(state)) +} diff --git a/cmd/entire/cli/strategy/session_state.go b/cmd/entire/cli/strategy/session_state.go index 3c61c2eeec..3a28c376b8 100644 --- a/cmd/entire/cli/strategy/session_state.go +++ b/cmd/entire/cli/strategy/session_state.go @@ -1030,3 +1030,22 @@ func ClearSessionState(ctx context.Context, sessionID string) error { // harmless. Bulk cleanup happens via RemoveAll on uninstall. return nil } + +// AccumulateSessionTokenUsage adds a token-usage delta to both the +// session-cumulative total and the checkpoint-scoped accumulator, mirroring +// SaveStep's accounting (manual_commit_git.go). It exists for turns that end +// with no uncommitted changes — e.g. Antigravity committing all its work +// mid-turn, its normal flow — where the TurnEnd handler skips SaveStep +// entirely but the turn's out-of-band token delta must still be recorded so +// the next condensation (or `entire status`) attributes it. A nil or empty +// delta is a no-op. +func AccumulateSessionTokenUsage(ctx context.Context, sessionID string, delta *agent.TokenUsage) error { + if delta == nil { + return nil + } + return MutateSessionState(ctx, sessionID, func(state *SessionState) error { + state.TokenUsage = accumulateTokenUsage(state.TokenUsage, delta) + state.CheckpointTokenUsage = accumulateTokenUsage(state.CheckpointTokenUsage, delta) + return nil + }) +} diff --git a/cmd/entire/cli/strategy/transcript_offset_pending_test.go b/cmd/entire/cli/strategy/transcript_offset_pending_test.go new file mode 100644 index 0000000000..37d1825bff --- /dev/null +++ b/cmd/entire/cli/strategy/transcript_offset_pending_test.go @@ -0,0 +1,188 @@ +package strategy + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/session" + "github.com/stretchr/testify/require" + + // Register the Antigravity agent so GetByAgentType resolves it. + _ "github.com/entireio/cli/cmd/entire/cli/agent/antigravity" +) + +// Two non-blank agy step lines — the "previous turn" content. +const agyTurnOneContent = `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"first prompt"} +{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","content":"done"} +` + +// Turn-one content plus a second turn appended — the post-flush file state. +const agyTwoTurnContent = agyTurnOneContent + `{"step_index":2,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"second prompt"} +{"step_index":3,"source":"MODEL","type":"PLANNER_RESPONSE","content":"also done"} +` + +func writeAgyTranscript(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "transcript_full.jsonl") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +// TestAdvanceOffsetToTurnEnd_FlushLanded: the normal case — agy flushed before +// Stop, the advance moves the offset to the file end and no retry is pending. +func TestAdvanceOffsetToTurnEnd_FlushLanded(t *testing.T) { + t.Parallel() + state := &SessionState{ + SessionID: "agy-flush-landed", + AgentType: agent.AgentTypeAntigravity, + TranscriptPath: writeAgyTranscript(t, agyTwoTurnContent), + CheckpointTranscriptStart: 2, + } + advanceCheckpointTranscriptStartToTurnEnd(context.Background(), state) + require.Equal(t, 4, state.CheckpointTranscriptStart, "offset must advance to the flushed file end") + require.False(t, state.TranscriptOffsetPending, "successful advance must not leave a pending retry") +} + +// TestAdvanceOffsetToTurnEnd_FlushLost: agy lost the flush race at Stop — the +// file still shows the previous state, so the advance can't move and the retry +// must be recorded for the next condensation. +func TestAdvanceOffsetToTurnEnd_FlushLost(t *testing.T) { + t.Parallel() + state := &SessionState{ + SessionID: "agy-flush-lost", + AgentType: agent.AgentTypeAntigravity, + TranscriptPath: writeAgyTranscript(t, agyTurnOneContent), + CheckpointTranscriptStart: 2, // already at the (stale) file end + } + advanceCheckpointTranscriptStartToTurnEnd(context.Background(), state) + require.Equal(t, 2, state.CheckpointTranscriptStart, "stale file must not move the offset") + require.True(t, state.TranscriptOffsetPending, "lost flush race must defer the advance to the next condensation") +} + +// TestAdvanceOffsetToTurnEnd_StreamingAgentNeverPends: for streaming-transcript +// agents a non-growing transcript legitimately means "no new content" — the +// deferred-advance machinery must stay agy-only. +func TestAdvanceOffsetToTurnEnd_StreamingAgentNeverPends(t *testing.T) { + t.Parallel() + state := &SessionState{ + SessionID: "claude-no-growth", + AgentType: agent.AgentTypeClaudeCode, + TranscriptPath: writeAgyTranscript(t, agyTurnOneContent), + CheckpointTranscriptStart: 2, + } + advanceCheckpointTranscriptStartToTurnEnd(context.Background(), state) + require.False(t, state.TranscriptOffsetPending, "streaming agents must never set TranscriptOffsetPending") +} + +// TestResolvePendingTranscriptOffset_MidTurn is the a1 prompt-shift regression +// pin. Sequence being modeled: turn 1 commits mid-turn (condensation counted +// the then-empty transcript, offset stayed 0), turn 1's Stop lost the flush +// race (pending set), the flush landed between turns, and now turn 2 commits +// mid-turn. Mid-turn the file contains exactly turn 1 — agy flushes only after +// Stop — so without the deferred advance, prompt extraction at the stale +// offset attributes turn 1's prompt to turn 2's checkpoint. +func TestResolvePendingTranscriptOffset_MidTurn(t *testing.T) { + t.Parallel() + ag, err := agent.GetByAgentType(agent.AgentTypeAntigravity) + require.NoError(t, err) + + path := writeAgyTranscript(t, agyTurnOneContent) + state := &SessionState{ + SessionID: "agy-resolve-pending", + AgentType: agent.AgentTypeAntigravity, + Phase: session.PhaseActive, + TranscriptPath: path, + CheckpointTranscriptStart: 0, // stale: counted from the unflushed file at turn 1's commit + TranscriptOffsetPending: true, + } + + // The misattribution without the fix: at the stale offset, the previous + // turn's prompt is what extraction hands to turn 2's checkpoint. + stale := resolvePromptsFromLateFlushedTranscript(context.Background(), ag, path, state.CheckpointTranscriptStart) + require.Equal(t, []string{"first prompt"}, stale, + "sanity: the stale offset attributes the previous turn's prompt to the current checkpoint") + + resolvePendingTranscriptOffset(context.Background(), ag, state) + require.Equal(t, 2, state.CheckpointTranscriptStart, "pending advance must complete against the flushed file end") + require.False(t, state.TranscriptOffsetPending, "flag is one-shot") + + corrected := resolvePromptsFromLateFlushedTranscript(context.Background(), ag, path, state.CheckpointTranscriptStart) + require.Empty(t, corrected, + "at the corrected offset no already-condensed prompt leaks into the current checkpoint") +} + +// TestResolvePendingTranscriptOffset_NotActive: outside ACTIVE phase the file +// may already contain the current turn's uncondensed content, so the advance +// must be skipped — and the one-shot flag still cleared. +func TestResolvePendingTranscriptOffset_NotActive(t *testing.T) { + t.Parallel() + ag, err := agent.GetByAgentType(agent.AgentTypeAntigravity) + require.NoError(t, err) + state := &SessionState{ + SessionID: "agy-resolve-idle", + AgentType: agent.AgentTypeAntigravity, + Phase: session.PhaseIdle, + TranscriptPath: writeAgyTranscript(t, agyTwoTurnContent), + CheckpointTranscriptStart: 2, + TranscriptOffsetPending: true, + } + resolvePendingTranscriptOffset(context.Background(), ag, state) + require.Equal(t, 2, state.CheckpointTranscriptStart, "idle session must not advance (file may hold uncondensed content)") + require.False(t, state.TranscriptOffsetPending, "flag is one-shot even when the advance is skipped") +} + +// TestResolvePendingTranscriptOffset_NoFlag: without the flag the helper must +// not touch the offset at all. +func TestResolvePendingTranscriptOffset_NoFlag(t *testing.T) { + t.Parallel() + ag, err := agent.GetByAgentType(agent.AgentTypeAntigravity) + require.NoError(t, err) + state := &SessionState{ + SessionID: "agy-resolve-noflag", + AgentType: agent.AgentTypeAntigravity, + Phase: session.PhaseActive, + TranscriptPath: writeAgyTranscript(t, agyTwoTurnContent), + CheckpointTranscriptStart: 2, + } + resolvePendingTranscriptOffset(context.Background(), ag, state) + require.Equal(t, 2, state.CheckpointTranscriptStart) +} + +// TestResolvePendingTranscriptOffset_StillUnflushedKeepsPending covers the case +// the flag exists for: an ACTIVE session whose transcript has STILL not +// flushed when the next condensation runs. Clearing the flag there discarded +// the deferral, so the advance never happened once the flush landed and the +// next checkpoint scoped from an offset inside the previous, already-condensed +// turn — the prompt shift the flag was added to prevent. The flag must survive +// an attempt that could not compute the advance, and resolve on a later one. +func TestResolvePendingTranscriptOffset_StillUnflushedKeepsPending(t *testing.T) { + t.Parallel() + ag, err := agent.GetByAgentType(agent.AgentTypeAntigravity) + require.NoError(t, err) + + // The empty placeholder PrepareTranscript materialises when Stop beats the + // flush: position 0, equal to the stale offset, so no advance is possible. + path := writeAgyTranscript(t, "") + state := &SessionState{ + SessionID: "agy-resolve-still-unflushed", + AgentType: agent.AgentTypeAntigravity, + Phase: session.PhaseActive, + TranscriptPath: path, + CheckpointTranscriptStart: 0, + TranscriptOffsetPending: true, + } + + resolvePendingTranscriptOffset(context.Background(), ag, state) + require.Equal(t, 0, state.CheckpointTranscriptStart, "nothing to advance to while the transcript is empty") + require.True(t, state.TranscriptOffsetPending, + "an attempt that could not compute the advance must leave the deferral pending") + + // The flush lands; the next condensation must still complete the advance. + require.NoError(t, os.WriteFile(path, []byte(agyTurnOneContent), 0o600)) + resolvePendingTranscriptOffset(context.Background(), ag, state) + require.Equal(t, 2, state.CheckpointTranscriptStart, "the deferred advance completes once the flush lands") + require.False(t, state.TranscriptOffsetPending, "a completed advance clears the flag") +} diff --git a/cmd/entire/cli/summarize/claude.go b/cmd/entire/cli/summarize/claude.go index b5d652ab5d..859afb40ca 100644 --- a/cmd/entire/cli/summarize/claude.go +++ b/cmd/entire/cli/summarize/claude.go @@ -78,7 +78,7 @@ type ClaudeGenerator struct { // Generate creates a summary from checkpoint data by calling the Claude CLI. func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoint.Summary, error) { // Format the transcript for the prompt - transcriptText := FormatCondensedTranscript(input) + transcriptText := FormatCondensedTranscriptForPrompt(input) // Build the prompt prompt := buildSummarizationPrompt(transcriptText) diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index b64483f542..a9d17a8692 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -10,6 +10,7 @@ import ( "strings" "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/factoryaidroid" "github.com/entireio/cli/cmd/entire/cli/agent/opencode" "github.com/entireio/cli/cmd/entire/cli/agent/types" @@ -163,6 +164,8 @@ func BuildCondensedTranscriptFromBytes(content redact.RedactedBytes, agentType t return buildCondensedTranscriptFromCodex(content) case agent.AgentTypePi: return buildCondensedTranscriptFromPi(content) + case agent.AgentTypeAntigravity: + return buildCondensedTranscriptFromAntigravity(content), nil case agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeUnknown: // Claude/cursor format - fall through to shared logic below } @@ -271,6 +274,32 @@ func buildCondensedTranscriptFromGemini(redacted redact.RedactedBytes) ([]Entry, return entries, nil } +// buildCondensedTranscriptFromAntigravity condenses agy's step JSONL. The +// format knowledge lives in the antigravity package (CondenseTranscript); this +// only maps its roles onto Entry. agy tool args name the file as TargetFile, +// which extractGenericToolDetail does not know, so it is offered as file_path +// too. +func buildCondensedTranscriptFromAntigravity(redacted redact.RedactedBytes) []Entry { + var entries []Entry + for _, step := range antigravity.CondenseTranscript(redacted.Bytes()) { + switch step.Role { + case antigravity.CondensedRoleUser: + entries = append(entries, Entry{Type: EntryTypeUser, Content: step.Text}) + case antigravity.CondensedRoleAssistant: + entries = append(entries, Entry{Type: EntryTypeAssistant, Content: step.Text}) + case antigravity.CondensedRoleTool: + args := step.ToolArgs + if target, ok := args["TargetFile"].(string); ok && target != "" { + if _, has := args["file_path"]; !has { + args["file_path"] = target + } + } + entries = append(entries, Entry{Type: EntryTypeTool, ToolName: step.ToolName, ToolDetail: extractGenericToolDetail(args)}) + } + } + return entries +} + // buildCondensedTranscriptFromOpenCode parses OpenCode export JSON transcript and extracts a condensed view. func buildCondensedTranscriptFromOpenCode(redacted redact.RedactedBytes) ([]Entry, error) { session, err := opencode.ParseExportSession(redacted.Bytes()) @@ -556,7 +585,7 @@ func extractToolDetail(toolName string, input transcript.ToolInput) string { return input.Pattern } -// FormatCondensedTranscript formats an Input into a human-readable string for LLM. +// FormatCondensedTranscript formats an Input into a human-readable string. // The format is: // // [User] user prompt here @@ -564,7 +593,22 @@ func extractToolDetail(toolName string, input transcript.ToolInput) string { // [Assistant] assistant response here // // [Tool] ToolName: description or file path +// +// It is unbounded: `entire explain --full` renders it for a reader who asked +// for the whole transcript. Summary prompts go through +// FormatCondensedTranscriptForPrompt, which applies the size bounds. func FormatCondensedTranscript(input Input) string { + return formatCondensedTranscript(input, false) +} + +// FormatCondensedTranscriptForPrompt is FormatCondensedTranscript with the +// transcript and file-list bounds a summary prompt needs (see +// maxCondensedTranscriptBytes). Every LLM-bound caller uses this one. +func FormatCondensedTranscriptForPrompt(input Input) string { + return formatCondensedTranscript(input, true) +} + +func formatCondensedTranscript(input Input, bounded bool) string { var sb strings.Builder for i, entry := range input.Transcript { @@ -592,12 +636,78 @@ func FormatCondensedTranscript(input Input) string { } } + out := sb.String() + if bounded { + out = boundTranscriptText(out) + } + + // Appended after the transcript bound, under its own: the file list is the + // part a summary can least afford to lose, but it is not short by + // construction — a wide refactor names thousands of paths — and the argv + // ceiling that sizes maxCondensedTranscriptBytes applies to the whole + // prompt, so an unbounded tail would reopen the E2BIG failure the + // transcript bound closed. if len(input.FilesTouched) > 0 { - sb.WriteString("\n[Files Modified]\n") - for _, file := range input.FilesTouched { - fmt.Fprintf(&sb, "- %s\n", file) - } + out += formatFilesList(input.FilesTouched, bounded) } + return out +} + +// maxCondensedTranscriptBytes bounds the transcript text a summary prompt +// carries. Two independent reasons, and the tighter one sets the number: +// +// - Cost. Every byte here becomes prompt tokens, on every provider, on every +// checkpoint. Long sessions were previously unbounded. +// - argv. Antigravity takes its prompt as a single argv element, because agy +// ignores stdin in print mode (see antigravity.GenerateText). Linux caps a +// SINGLE argument at MAX_ARG_STRLEN — 128 KiB — regardless of the much +// larger total ARG_MAX, so an unbounded transcript fails with E2BIG on +// exactly the long sessions a summary is most wanted for. macOS's ~1 MiB +// total is permissive enough to hide this in local testing. +// +// 96 KiB, plus maxCondensedFilesBytes for the file list, keeps the whole prompt +// inside that 128 KiB once buildSummarizationPrompt's wrapper is added. It does NOT rescue Windows, +// which caps an entire command line near 32 KiB; that limit is narrower than +// any transcript budget worth having and needs a different fix. +const maxCondensedTranscriptBytes = 96 * 1024 + +// maxCondensedFilesBytes bounds the [Files Modified] list that follows the +// transcript. 16 KiB is a few hundred paths; past that a summary needs the +// count, not the names, and the total stays inside the argv ceiling above. +const maxCondensedFilesBytes = 16 * 1024 + +// formatFilesList renders the [Files Modified] section. When bounded, it +// keeps whole lines up to maxCondensedFilesBytes and closes with how many +// paths were left out. +func formatFilesList(files []string, bounded bool) string { + var sb strings.Builder + sb.WriteString("\n[Files Modified]\n") + for i, file := range files { + line := "- " + file + "\n" + if bounded && sb.Len()+len(line) > maxCondensedFilesBytes { + fmt.Fprintf(&sb, "- [... %d more files omitted to fit the summary prompt ...]\n", len(files)-i) + break + } + sb.WriteString(line) + } return sb.String() } + +// boundTranscriptText caps transcript text at maxCondensedTranscriptBytes, +// dropping from the middle. Both ends carry the most signal for a summary — +// the opening says what was asked, the tail says how it ended — and a middle +// cut is the one that keeps both. +func boundTranscriptText(s string) string { + if len(s) <= maxCondensedTranscriptBytes { + return s + } + + const marker = "\n[... transcript truncated to fit the summary prompt ...]\n" + keep := maxCondensedTranscriptBytes - len(marker) + head := keep / 2 + + // ToValidUTF8 drops the partial rune a byte-offset cut can leave at either + // new edge. + return strings.ToValidUTF8(s[:head], "") + marker + strings.ToValidUTF8(s[len(s)-(keep-head):], "") +} diff --git a/cmd/entire/cli/summarize/summarize_test.go b/cmd/entire/cli/summarize/summarize_test.go index 35014c033a..3860e319e8 100644 --- a/cmd/entire/cli/summarize/summarize_test.go +++ b/cmd/entire/cli/summarize/summarize_test.go @@ -8,6 +8,7 @@ import ( "slices" "strings" "testing" + "unicode/utf8" "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" @@ -1228,3 +1229,120 @@ func TestResolveModel(t *testing.T) { }) } } + +// An agy transcript used to fall through to the Claude parser and condense to +// nothing, so `explain --generate` with agy as the provider failed with +// "transcript has no content to summarize" on a 2.4 KB transcript. +func TestBuildCondensedTranscriptFromBytes_Antigravity(t *testing.T) { + t.Parallel() + agy := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","content":"\nCreate red.md\n"} +{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","tool_calls":[{"name":"write_to_file","args":{"TargetFile":"\"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/ws/red.md\""}}]} +{"step_index":2,"source":"MODEL","type":"GENERIC","status":"DONE","content":"tool output"} +{"step_index":3,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","content":"Created red.md."} +` + entries, err := BuildCondensedTranscriptFromBytes(redact.AlreadyRedacted([]byte(agy)), agent.AgentTypeAntigravity) + if err != nil { + t.Fatalf("BuildCondensedTranscriptFromBytes: %v", err) + } + if len(entries) != 3 { + t.Fatalf("got %d entries, want 3: %+v", len(entries), entries) + } + if entries[0].Type != EntryTypeUser || entries[0].Content != "Create red.md" { + t.Errorf("entry 0 = %+v, want the user request", entries[0]) + } + if entries[1].Type != EntryTypeTool || entries[1].ToolName != "write_to_file" || entries[1].ToolDetail != "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/ws/red.md" { + t.Errorf("entry 1 = %+v, want the tool call with its target file as detail", entries[1]) + } + if entries[2].Type != EntryTypeAssistant || entries[2].Content != "Created red.md." { + t.Errorf("entry 2 = %+v, want the assistant text", entries[2]) + } +} + +// A summary prompt travels to Antigravity as a single argv element, and Linux +// caps one argument at MAX_ARG_STRLEN (128 KiB) however large ARG_MAX is. An +// unbounded transcript therefore failed with E2BIG on exactly the long sessions +// a summary is most wanted for, so the formatted transcript carries a budget. +// The file list has its own bound: FilesTouched is unbounded, and the argv +// ceiling behind maxCondensedTranscriptBytes applies to the whole prompt. +func TestFormatCondensedTranscript_BoundsOversizedFileLists(t *testing.T) { + t.Parallel() + files := make([]string, 0, 4000) + for i := range 4000 { + files = append(files, fmt.Sprintf("pkg/module%04d/deeply/nested/directory/structure/file_with_a_long_name_%04d.go", i, i)) + } + result := FormatCondensedTranscriptForPrompt(Input{ + Transcript: []Entry{{Type: EntryTypeUser, Content: "touch everything"}}, + FilesTouched: files, + }) + if len(result) > maxCondensedTranscriptBytes+maxCondensedFilesBytes+512 { + t.Fatalf("formatted prompt is %d bytes; the file list must be bounded too", len(result)) + } + if !strings.Contains(result, "- pkg/module0000/") { + t.Error("the first files must survive the bound") + } + if !strings.Contains(result, "more files omitted to fit the summary prompt") { + t.Error("the bound must say how many files were left out") + } + // The display formatter is never bounded: a reader who asked for the full + // transcript gets every file. + full := FormatCondensedTranscript(Input{FilesTouched: files}) + if strings.Contains(full, "omitted") || !strings.Contains(full, "- pkg/module3999/") { + t.Error("FormatCondensedTranscript must render the whole file list for display") + } + // A short list is untouched. + short := FormatCondensedTranscriptForPrompt(Input{FilesTouched: []string{"a.go", "b.go"}}) + if strings.Contains(short, "omitted") || !strings.Contains(short, "- b.go\n") { + t.Errorf("a short file list must be rendered in full, got %q", short) + } +} + +func TestFormatCondensedTranscript_BoundsOversizedTranscripts(t *testing.T) { + t.Parallel() + + // Comfortably past the budget, in entries no single one of which exceeds it. + var entries []Entry + for range 40 { + entries = append(entries, Entry{Type: EntryTypeAssistant, Content: strings.Repeat("x", 8*1024)}) + } + input := Input{ + Transcript: append([]Entry{{Type: EntryTypeUser, Content: "OPENING MARKER"}}, entries...), + FilesTouched: []string{"/kept.go"}, + } + + result := FormatCondensedTranscriptForPrompt(input) + if display := FormatCondensedTranscript(input); strings.Contains(display, "truncated to fit the summary prompt") { + t.Error("the display formatter must not truncate; only the prompt variant is bounded") + } + + if len(result) > maxCondensedTranscriptBytes+512 { + t.Errorf("formatted transcript is %d bytes, want <= budget (%d) plus the files list", + len(result), maxCondensedTranscriptBytes) + } + if !strings.Contains(result, "truncated to fit the summary prompt") { + t.Error("an over-budget transcript must say it was truncated") + } + // Both ends survive: the opening says what was asked, the files list is + // appended after bounding. + if !strings.Contains(result, "OPENING MARKER") { + t.Error("the start of the session was dropped; the cut must come from the middle") + } + if !strings.Contains(result, "- /kept.go") { + t.Error("the files list must survive bounding") + } + if !utf8.ValidString(result) { + t.Error("bounding cut a rune in half") + } +} + +// Under budget, nothing is touched. +func TestFormatCondensedTranscript_LeavesSmallTranscriptsAlone(t *testing.T) { + t.Parallel() + + result := FormatCondensedTranscript(Input{ + Transcript: []Entry{{Type: EntryTypeUser, Content: "short"}}, + }) + + if strings.Contains(result, "truncated") { + t.Errorf("a short transcript must not be truncated, got %q", result) + } +} diff --git a/cmd/entire/cli/summarize/text_generator.go b/cmd/entire/cli/summarize/text_generator.go index 02df905c44..eb5e19ad84 100644 --- a/cmd/entire/cli/summarize/text_generator.go +++ b/cmd/entire/cli/summarize/text_generator.go @@ -34,7 +34,7 @@ func (g *TextGeneratorAdapter) Generate(ctx context.Context, input Input) (*chec if g.TextGenerator == nil { return nil, errors.New("text generator not configured") } - transcriptText := FormatCondensedTranscript(input) + transcriptText := FormatCondensedTranscriptForPrompt(input) prompt := buildSummarizationPrompt(transcriptText) // Prefer streaming when the underlying agent supports it. TextGenerator diff --git a/docs/architecture/agent-guide.md b/docs/architecture/agent-guide.md index 5ef317843e..771be47052 100644 --- a/docs/architecture/agent-guide.md +++ b/docs/architecture/agent-guide.md @@ -982,6 +982,46 @@ absPath := filepath.Join(repoRoot, file) Some agents write transcripts asynchronously. If `ReadTranscript` is called before the write completes, the transcript will be incomplete. Implement `TranscriptPreparer` if your agent has this behavior. Claude Code solves this by writing a sentinel entry and polling for it (see `waitForTranscriptFlush` in `claudecode/lifecycle.go`). +### Antigravity (agy) Wire-Format Quirks + +Captured from real agy stdin (1.0.x); all enforced by tests in `agent/antigravity/`: + +- **`invocationNum` is 0-indexed** — the first model invocation of a conversation is `0`. PreInvocation fires per *model invocation*, not per user prompt, so only `invocationNum == 0` maps directly to TurnStart; `invocationNum > 0` emits a TurnStart with `Event.SuppressIfSessionActive` and the dispatcher drops it when a turn is genuinely mid-flight (resumes via `agy --conversation` start at `> 0` and must still be tracked). +- **Transcript is written AFTER the Stop hook** — `PrepareTranscript` briefly waits, then materialises an empty placeholder; condensation degrades to a files/prompt-only checkpoint and re-extracts prompts late (`resolvePromptsFromLateFlushedTranscript`). The hook payload's `transcriptPath` points at `transcript_full.jsonl` under `~/.gemini/antigravity-cli/brain//.system_generated/logs/`. +- **Tool args can be double-encoded** — `toolCall.args` values sometimes arrive as JSON strings containing JSON; see `decodeAgyString`/`decodeAgyBool`. +- **Token usage has exactly one surface** — the statusline/title JSON payload (`context_window`). The integration tees it to disk via the global settings.json `title` slot (`entire hooks antigravity title-tee`) and implements `OutOfBandTokenSource`. +- **No SessionStart hook surface** — there is no way to show a "tracked by entire" banner; agy tracks silently like Cursor/OpenCode/Copilot/Pi. Only PreToolUse, PreInvocation, and Stop are installed — agy's PostToolUse/PostInvocation have no lifecycle mapping and are deliberately not installed. + +#### Antigravity status: Preview + +The integration is preview status. That is a documentation statement, not a +code flag: `agent.IsPreview` was removed from the Agent interface (#2554), so +nothing in the CLI renders a label. Known limitations while in preview: + +- **No in-agy banner**: tracking is silent inside the agy UI (no SessionStart + hook surface). `entire status` is the visibility surface. +- **Mid-turn commit token scoping is coarse**: a checkpoint created by a + mid-turn agy commit records zero tokens; the turn's delta lands on the next + condensation. Session totals stay correct. +- **First-turn mid-turn commits may checkpoint without transcript content** + (files + prompt only): agy writes its transcript after the Stop hook, so the + condensation can run against the empty placeholder. Prompts are recovered on + the next condensation via the late-flush fallback. +- **Token capture depends on the global title slot**: `entire hooks antigravity + title-tee` must own (or wrap) agy's `title` command in the global + settings.json. `entire doctor` checks this and setup repairs it. +- **Live E2E / CI runs in agy's Gemini API-key mode** (≥ 1.1.13 for auth, + ≥ 1.1.25 for hooks to execute on that route — earlier releases loaded them + and never ran them, upstream #893). CI installs the latest agy. The default + `cloudcode-pa` backend (OAuth/ADC) remains entitlement-gated. See + `e2e/README.md` → "Antigravity credentials". +- **Wire format captured on agy 1.0.14/1.0.15, re-verified unchanged on agy + 1.1.1** (2026-07-13, docs + binary + live run): hook payloads, hooks.json + shape, and the statusline/title schema are stable so far, but agy is + fast-moving — skill directories already moved in 1.1 (now + `/.agents/skills` and `~/.gemini/config/skills`; discovery scans + new and legacy roots). Re-verify the contract when agy versions bump. + ### Nil Event Return Pattern `ParseHookEvent` returning `(nil, nil)` is **not an error** - it means the hook has no lifecycle significance. The framework (in `hook_registry.go`) checks: @@ -998,8 +1038,8 @@ Use `//nolint:nilnil` to suppress the linter warning on intentional nil returns. ### Agent Name vs Agent Type -- `AgentName` is the **registry key** used in code (`"claude-code"`, `"codex"`, `"opencode"`, `"cursor"`, `"factoryai-droid"`, `"copilot-cli"`). It appears in CLI commands: `entire hooks cursor stop`. -- `AgentType` is the **display name** stored in metadata and commit trailers (`"Claude Code"`, `"Codex"`, `"OpenCode"`, `"Cursor"`, `"Factory AI Droid"`, `"Copilot CLI"`). It's what users see. +- `AgentName` is the **registry key** used in code (`"claude-code"`, `"codex"`, `"opencode"`, `"cursor"`, `"factoryai-droid"`, `"copilot-cli"`, `"antigravity"`). It appears in CLI commands: `entire hooks cursor stop`. +- `AgentType` is the **display name** stored in metadata and commit trailers (`"Claude Code"`, `"Codex"`, `"OpenCode"`, `"Cursor"`, `"Factory AI Droid"`, `"Copilot CLI"`, `"Antigravity"`). It's what users see. Register constants for both in `cmd/entire/cli/agent/registry.go` when adding a new agent. diff --git a/docs/architecture/agent-integration-checklist.md b/docs/architecture/agent-integration-checklist.md index e87e49c91c..73ca88dd7c 100644 --- a/docs/architecture/agent-integration-checklist.md +++ b/docs/architecture/agent-integration-checklist.md @@ -45,7 +45,7 @@ See Guide: [Transcript Format Guide](agent-guide.md#transcript-format-guide), [T - [ ] **Full transcript on every turn**: At turn-end, capture the complete session transcript, not just events since the last checkpoint - [ ] **Resumed session handling**: When a user resumes an existing session, the transcript must include all historical messages, not just new ones since the plugin/hook loaded -- [ ] **Use agent's canonical export**: Prefer the agent's native export command (e.g., reading Claude's JSONL file, Cursor's JSONL, Factory AI Droid's JSONL, Copilot CLI's JSONL, OpenCode's `opencode export` JSON, Pi's JSONL session file) over manually reconstructing from events +- [ ] **Use agent's canonical export**: Prefer the agent's native export command (e.g., reading Claude's JSONL file, Cursor's JSONL, Factory AI Droid's JSONL, Copilot CLI's JSONL, OpenCode's `opencode export` JSON, Pi's JSONL session file, Antigravity's brain-dir transcript_full.jsonl) over manually reconstructing from events - [ ] **No custom formats**: Store the agent's native format directly in `NativeData` - do not convert between formats (e.g., JSON to JSONL) or create intermediate representations - [ ] **Graceful degradation**: If the canonical source is unavailable (e.g., agent shutting down), fall back to best-effort capture with clear documentation of limitations @@ -54,7 +54,7 @@ See Guide: [Transcript Format Guide](agent-guide.md#transcript-format-guide), [T See Guide: [Step 3 - Core Agent Interface](agent-guide.md#step-3-implement-core-agent-interface-youragentgo) - [ ] **`WriteSession` implementation**: Agent must implement `WriteSession(AgentSession)` to restore sessions -- [ ] **File-based agents** (Claude, Cursor, Factory AI Droid, Copilot CLI, Pi): Write `NativeData` to `SessionRef` path +- [ ] **File-based agents** (Claude, Cursor, Factory AI Droid, Copilot CLI, Pi, Antigravity): Write `NativeData` to `SessionRef` path - [ ] **Database-backed agents** (OpenCode): Write `NativeData` to file, then import into native storage (the native format should be what the agent's import command expects) - [ ] **Single format per agent**: Store only the agent's native format in `NativeData` - no separate fields for different representations of the same data diff --git a/docs/architecture/sessions-and-checkpoints.md b/docs/architecture/sessions-and-checkpoints.md index 351a17430d..1e003fd920 100644 --- a/docs/architecture/sessions-and-checkpoints.md +++ b/docs/architecture/sessions-and-checkpoints.md @@ -2,7 +2,7 @@ ## Overview -Entire CLI creates checkpoints for AI coding sessions. The system is agent-agnostic - it works with Claude Code, Codex, OpenCode, Cursor, Factory AI Droid, Copilot CLI, or any tool that triggers Entire hooks. +Entire CLI creates checkpoints for AI coding sessions. The system is agent-agnostic - it works with Claude Code, Codex, Antigravity, OpenCode, Cursor, Factory AI Droid, Copilot CLI, or any tool that triggers Entire hooks. This document covers the domain model shared by both checkpoint storage backends. For how the **git-refs** backend stores checkpoints as one ref per checkpoint — its layout, push/fetch model, read routing, and configuration — see [Ref-Based Checkpoint Backend](ref-checkpoint-backend.md). diff --git a/docs/development/filesystem-safety.md b/docs/development/filesystem-safety.md index 96be3c5c6e..ad98e5e236 100644 --- a/docs/development/filesystem-safety.md +++ b/docs/development/filesystem-safety.md @@ -235,7 +235,7 @@ to `os.ReadFile`/`os.WriteFile`/`os.MkdirAll`/`os.ReadDir`/`filepath.Walk`.** | `.entire` | `entiredir` | worktree root (`paths.WorktreeRoot`), cwd only when there is provably no repo | | git common dir | `gitdir` | `git rev-parse --git-common-dir`, absolutized | | the working tree | `worktreedir` | worktree root | -| an agent's hook config | `agent.HookConfigFile` | worktree root (`.claude/`, `.cursor/`, `.github/hooks/`, `.factory/`, `.codex/`, `.opencode/plugins/`, `.pi/extensions/entire/`; also `.gemini/` for the retired Gemini CLI hook cleanup) | +| an agent's hook config | `agent.HookConfigFile` | worktree root (`.claude/`, `.cursor/`, `.github/hooks/`, `.factory/`, `.codex/`, `.opencode/plugins/`, `.pi/extensions/entire/`, `.agents/`; also `.gemini/` for the retired Gemini CLI hook cleanup) | | an agent's session store | `agent.SessionStore` | the agent's own `GetSessionDir` | | the active git hooks dir | `strategy.hooksRootForInstall` / `ForRemoval` | `git rev-parse --git-path hooks`, absolutized | | per-user config / cache | `userdirs.ConfigRoot` / `CacheRoot` | `$ENTIRE_CONFIG_DIR` else `~/.config/entire`; `$XDG_CACHE_HOME/entire` else `~/.cache/entire` | diff --git a/docs/first-time-contributors.md b/docs/first-time-contributors.md index c93a709306..0f69ba5ffb 100644 --- a/docs/first-time-contributors.md +++ b/docs/first-time-contributors.md @@ -97,7 +97,7 @@ If `mise run test` passes, you're good to go. If something failed, see [Troubles Entire exists to help you work with AI coding agents, so it would be odd if you weren't using one to contribute. There's no need to tell us you did. Our general thinking: use whatever agent and methodology you like, but until the robot revolution comes, you are responsible for the final code. Before submitting a PR for review, make sure you have reviewed it yourself. We'll close PRs that obviously skipped this step. -Entire supports agents including Claude Code, Codex, OpenCode, Cursor, Factory AI Droid, Copilot CLI, and Pi, so feel free to use whichever one you're most comfortable with. Whichever you choose, your session will be captured the same way (see [Using Entire while you contribute](#using-entire-while-you-contribute) below). +Entire supports agents including Claude Code, Codex, Antigravity, OpenCode, Cursor, Factory AI Droid, Copilot CLI, and Pi, so feel free to use whichever one you're most comfortable with. Whichever you choose, your session will be captured the same way (see [Using Entire while you contribute](#using-entire-while-you-contribute) below). One thing to watch out for is LLM eagerness. Agents like to please and they're in a hurry. A few common failure modes to push back on: diff --git a/docs/security-and-privacy.md b/docs/security-and-privacy.md index b193c438dd..25f1332a7a 100644 --- a/docs/security-and-privacy.md +++ b/docs/security-and-privacy.md @@ -6,7 +6,7 @@ Entire stores AI session transcripts and metadata in your git repository. This d ### Where data is stored -When you use Entire with an AI agent (Claude Code, Codex, OpenCode, Cursor, Factory AI Droid, Copilot CLI, Pi), session transcripts, user prompts, and checkpoint metadata are committed to **your own git repository**. They stay out of your working branches' history, but they live in the same repo and travel with it. +When you use Entire with an AI agent (Claude Code, Codex, Antigravity, OpenCode, Cursor, Factory AI Droid, Copilot CLI, Pi), session transcripts, user prompts, and checkpoint metadata are committed to **your own git repository**. They stay out of your working branches' history, but they live in the same repo and travel with it. Exactly where depends on the [checkpoint backend](architecture/ref-checkpoint-backend.md) the repo uses: @@ -19,6 +19,8 @@ Which one a repo is on is recorded as `checkpoints.primary.type` in `.entire/set Entire also creates temporary local **shadow branches** (e.g. `entire/-`) as working storage during a session, on both backends. Metadata written there — transcripts, prompts, incremental checkpoint data, subagent transcripts — goes through the same redaction pipeline as a committed checkpoint. **Code-file snapshots, however, are written as raw blobs of your working tree without redaction**, so any hardcoded secrets in your source code would appear unredacted on the shadow branch. Gitignored files (e.g., `.env`) are filtered out of these snapshots as a partial defense. Shadow branches are **not** pushed by Entire; do not push them manually, because unredacted source content would be visible on the remote. They are cleaned up when session data is condensed into a checkpoint at commit time. +**Antigravity title-tee (machine-global):** setting up the Antigravity agent installs `entire hooks antigravity title-tee` into agy's *global* settings (`~/.gemini/antigravity-cli/settings.json`), because agy exposes token usage only through its window-title/statusline feed. Once installed, the tee runs on every agy state change on the machine — including in repositories where Entire is not enabled — and persists **only** the conversation ID and token counts (`context_window` totals) to Entire's local cache directory; the rest of the payload is discarded and no prompt or file content is captured. Stale per-conversation snapshots are cleaned up after 14 days. Removing the Antigravity agent (`entire agent remove antigravity`) uninstalls the tee. + Anyone with access to your repository can read committed checkpoint data: the full prompt/response history and session metadata. Note that transcripts capture all tool interactions — including file contents, MCP server calls, and other data exchanged during the session. Per-checkpoint refs are less *visible* than a branch, but they are not less accessible: a `git fetch` of `refs/entire/checkpoints/*` reads them just as well. If your repository is **public**, this data is visible to the entire internet. diff --git a/e2e/README.md b/e2e/README.md index 1afd04fe65..284bcbeaf3 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,12 +1,13 @@ # E2E Tests -End-to-end tests for the `entire` CLI against real agents (Claude Code, OpenCode, Codex, Cursor, Factory AI Droid, Copilot CLI). +End-to-end tests for the `entire` CLI against real agents (Claude Code, Antigravity, OpenCode, Codex, Cursor, Factory AI Droid, Copilot CLI). ## Commands ```bash mise run test:e2e [filter] # run filtered (or omit filter for all agents) mise run test:e2e --agent claude-code [filter] # Claude Code only +mise run test:e2e --agent antigravity [filter] # Antigravity only mise run test:e2e --agent opencode [filter] # OpenCode only mise run test:e2e --agent codex [filter] # Codex only mise run test:e2e --agent cursor [filter] # Cursor only @@ -59,13 +60,18 @@ Run it with `mise run test:e2e:controlplane [filter]`; the task installs the Pla | Variable | Description | Default | |----------|-------------|---------| -| `E2E_AGENT` | Runner filter (`claude-code`, `opencode`, `codex`, `cursor-cli`, `factoryai-droid`, `copilot-cli`, `pi`, `vogon`, `roger-roger`) | all registered | +| `E2E_AGENT` | Runner filter (`claude-code`, `antigravity`, `opencode`, `codex`, `cursor-cli`, `factoryai-droid`, `copilot-cli`, `pi`, `vogon`, `roger-roger`) | all registered | | `E2E_ENTIRE_BIN` | Path to a pre-built `entire` binary | builds from source | | `E2E_TIMEOUT` | Per-prompt timeout, overriding every runner's own default. A per-test `agents.WithPromptTimeout(...)` still wins over it, and a malformed value is a hard error rather than a silent fall back. | per runner: 60s (codex, copilot-cli), 90s (cursor), 2m (opencode), none (claude-code, droid, pi, vogon, roger-roger — bounded only by the scenario timeout) | +| `E2E_ANTIGRAVITY_MODEL` | Optional Antigravity model override (slug from `agy models`) | `gemini-3.8-flash-low` | | `E2E_KEEP_REPOS` | Set to `1` to preserve temp repos after test | unset | | `E2E_CHECKPOINT_STORE` | Checkpoint backend to run the suite against (`git-branch`, `git-refs`). Maps to the `ENTIRE_CHECKPOINTS_PRIMARY` override that every spawned binary/hook honors. | `git-branch` | | `E2E_ARTIFACT_DIR` | Override artifact output directory | `e2e/artifacts/` | | `ANTHROPIC_API_KEY` | Required for Claude Code | — | +| `GEMINI_API_KEY` | Enables Antigravity's API-key mode (agy ≥ 1.1.13; hooks execute on that route from agy 1.1.25); the default in CI | — | +| `GOOGLE_APPLICATION_CREDENTIALS` | Optional Antigravity ADC (service account); takes precedence over `GEMINI_API_KEY` when set | — | +| `GOOGLE_CLOUD_PROJECT` | Optional Antigravity ADC project override | ADC JSON `project_id` | +| `E2E_ANTIGRAVITY_PROJECT` | Optional Antigravity E2E project override; takes precedence over `GOOGLE_CLOUD_PROJECT` | ADC JSON `project_id` | | `OPENAI_API_KEY` | Required for Codex | — | | `COPILOT_GITHUB_TOKEN` | Required for Copilot CLI, unless a `copilot login` credential is already stored. `GH_TOKEN` and `GITHUB_TOKEN` also work — Copilot reads all three, in that order of precedence. A `gh auth login` alone is not enough: Copilot does not read gh's config. | — | | `E2E_KEEP_AGENT_HOME` | Set to `1` to preserve the isolated `COPILOT_HOME` a session ran under (holds Copilot's own logs) | unset | @@ -73,6 +79,62 @@ Run it with `mise run test:e2e:controlplane [filter]`; the task installs the Pla | `E2E_GH_PASSWORD` | Password of that GitHub test user | — | | `E2E_GH_TOTP_SECRET` | The account's authenticator-app setup key (base32, as GitHub displays it) | — | +### Antigravity credentials + +Antigravity E2E resolves its auth mode from the environment, in this order: + +1. **ADC** — `GOOGLE_APPLICATION_CREDENTIALS` set: agy runs against its default + (`cloudcode-pa`) backend with `USE_ADC=1`, an isolated per-repo `HOME`, and + `--project` from `E2E_ANTIGRAVITY_PROJECT`, `GOOGLE_CLOUD_PROJECT`, or the ADC + JSON `project_id`. This path needs a Gemini Code Assist entitlement on the + project (see caveat below). In GitHub Actions it is enabled by the optional + `ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON` secret. +2. **Gemini API key** — `GEMINI_API_KEY` set: agy ≥ 1.1.13 talks + to the Gemini API directly with no account session. The harness isolates + `HOME` per repo, writes `{"modelProvider":"gemini"}` into that home's + `~/.gemini/antigravity-cli/settings.json`, passes `GEMINI_API_KEY` (and + `GOOGLE_GEMINI_BASE_URL` if set) through, and scrubs `GOOGLE_API_KEY` — agy + prefers it over `GEMINI_API_KEY` when both are set. **Needs agy ≥ 1.1.25:** + up to 1.1.24 agy loaded `.agents/hooks.json` on this route but never executed + the hooks (google-antigravity/antigravity-cli#893, still open upstream; the + fix shipped silently in 1.1.25 — verified by bisecting the release binaries + with a probe hook), so Entire recorded nothing. CI installs the latest + release, so this is the default CI mode. The hook probe + (`E2E_ANTIGRAVITY_HOOK_PROBE=1`, log at `.entire/logs/agy-hook-probe.log` in + artifacts) is how to tell "hooks not executed" from "hooks not loaded". + +Both isolated modes also pre-trust the test repo (`trustedWorkspaces` in that +`settings.json`, agy's equivalent of `GEMINI_CLI_TRUST_WORKSPACE=true`): agy only +loads a workspace's `.agents/hooks.json` for a trusted workspace, so in a fresh +`HOME` a headless run would otherwise do the work with no Entire hooks firing. +They also seed `cache/onboarding.json` as already complete: interactive agy in a +never-run `HOME` shows a color-scheme chooser and a Terms of Service consent +before the prompt (headless `-p` skips both), and the tmux-driven +`TestInteractive*` tests otherwise time out with agy sat on the Terms screen. +The runner can also click through both screens if the seed stops being honoured. +3. **OAuth** — neither set: the developer's real `HOME` (existing `agy` login) is + used for local runs. + +The harness fails fast (no scenario restart) on the walls that don't clear on +retry: `Individual quota reached` / `SERVICE_DISABLED` / `AUTH_PERMISSION_DENIED` +(ADC/OAuth entitlement), `GEMINI_API_KEY environment variable is not set` +(API-key misconfiguration) and `API_KEY_INVALID`. + +> **ADC entitlement caveat:** agy's default backend (`cloudcode-pa.googleapis.com`) +> is a gated private API that cannot be enabled with `gcloud services enable` +> (fails with `AUTH_PERMISSION_DENIED`, subject 110002) — project Owner is not +> sufficient. ADC only works once a Gemini Code Assist Standard/Enterprise +> subscription is provisioned on the project; Code Assist licenses attach to user +> identities, so plain service accounts are not entitled. Prefer the API-key mode +> unless you specifically need the Code Assist backend. + +For local runs, either authenticate `agy` with OAuth, or: + +```bash +export GEMINI_API_KEY=... # API-key mode (agy >= 1.1.13) +mise run test:e2e --agent antigravity TestSingleSessionManualCommit +``` + ## Debugging Failures Artifacts are captured to `e2e/artifacts/` on every run (git-log, git-tree, console.log, checkpoint metadata, entire logs). Set `E2E_KEEP_REPOS=1` to preserve the temp repo — a symlink appears in the artifact dir pointing to it. @@ -99,7 +161,7 @@ To diagnose: read `console.log` in the failing test's artifact directory. Compar ## CI Workflows -- **`.github/workflows/e2e.yml`** runs the standard agent suite on pushes to main. +- **`.github/workflows/e2e.yml`** runs the standard agent suite on pushes to main. Antigravity runs in the standard suite in agy's Gemini API-key mode (see [Antigravity credentials](#antigravity-credentials)). - For debugging a single test, dispatch **`.github/workflows/e2e.yml`** with an agent and the optional `test` regex. An empty regex keeps the normal suite. The filter also reaches Windows when running Claude; selecting another agent skips the Windows Claude job. - **`.github/workflows/e2e-controlplane.yml`** runs the control-plane tests on pushes to main and on manual dispatch. Runs are serialized and never cancelled mid-flight, because the shared test account's resources are cleaned up by the test itself. diff --git a/e2e/agents/agent.go b/e2e/agents/agent.go index 6d0ee8de3e..b57036f39a 100644 --- a/e2e/agents/agent.go +++ b/e2e/agents/agent.go @@ -112,6 +112,29 @@ type Agent interface { IsTransientError(out Output, err error) bool } +// RepoPreparer is implemented by agents that need repo-local setup after +// `entire enable` (e.g. extra hook entries for diagnostics). Optional. +type RepoPreparer interface { + PrepareRepo(repoDir string) error +} + +// RepoCleaner is implemented by agents whose PrepareRepo (or prompt runs) leave +// state beside the test repo — Antigravity's isolated HOME is a sibling +// directory of it — so that state goes with the repo when the test cleans up. +// Called from the repo's t.Cleanup, after artifacts are captured. Optional. +type RepoCleaner interface { + CleanupRepo(repoDir string) error +} + +// ArtifactCollector is implemented by agents that keep state outside the test +// repo (an isolated HOME, the agent's own logs) worth capturing when artifacts +// are collected. Keys are artifact names (written under the test's artifact +// dir), values are source files or directories; missing sources are skipped. +// Optional. +type ArtifactCollector interface { + ExtraArtifacts(repoDir string) map[string]string +} + type Session interface { Send(input string) error WaitFor(pattern string, timeout time.Duration) (string, error) diff --git a/e2e/agents/antigravity.go b/e2e/agents/antigravity.go new file mode 100644 index 0000000000..a633fb7539 --- /dev/null +++ b/e2e/agents/antigravity.go @@ -0,0 +1,1027 @@ +package agents + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "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", antigravityDefaultConcurrency) +} + +const ( + antigravityBinary = "agy" + // antigravityDefaultModel is the model slug agy lists in `agy models` + // (gemini-3.8-flash-low ↔ "Gemini 3.8 Flash (Low)"). Slugs are the + // documented --model form and resolve in every auth mode. agy rejects a + // slug missing from its catalog outright ("invalid model selection"), and + // the catalog drops old models: gemini-3.5-flash-low vanished by agy 1.2.7, + // so bump this when `agy models` no longer lists it. + antigravityDefaultModel = "gemini-3.8-flash-low" + antigravityDefaultConcurrency = 1 + antigravityADCEnvKey = "USE_ADC" + googleCredentialsEnvKey = "GOOGLE_APPLICATION_CREDENTIALS" + googleCloudProjectEnvKey = "GOOGLE_CLOUD_PROJECT" + antigravityProjectEnvKey = "E2E_ANTIGRAVITY_PROJECT" + // geminiAPIKeyEnvKey enables agy's API-key auth (agy >= 1.1.13; hooks only + // execute on that route from agy 1.1.25, see antigravity/AGENT.md): with + // modelProvider "gemini" in the isolated HOME's settings.json, model + // requests go straight to the Gemini API and no account session, keyring + // or browser flow is involved. This is what lets antigravity run in the + // default CI matrix alongside gemini-cli, which shares the same secret. + geminiAPIKeyEnvKey = "GEMINI_API_KEY" + // googleAPIKeyEnvKey is scrubbed from every spawned env: agy prefers it + // over GEMINI_API_KEY when both are set ("Warning: Both GOOGLE_API_KEY and + // GEMINI_API_KEY are set. Using GOOGLE_API_KEY."), so a stray developer + // key must not silently replace the one the harness was told to use. + googleAPIKeyEnvKey = "GOOGLE_API_KEY" +) + +// antigravityAuthMode is how the spawned agy authenticates. Resolution order +// is deliberate: explicit ADC credentials are an antigravity-specific choice +// and win; GEMINI_API_KEY is ambient in CI (shared with gemini-cli) and is the +// default there; with neither, the developer's real HOME (interactive OAuth +// login) is used so local runs work without extra setup. +type antigravityAuthMode int + +const ( + antigravityAuthOAuth antigravityAuthMode = iota + antigravityAuthADC + antigravityAuthAPIKey +) + +func (m antigravityAuthMode) String() string { + switch m { + case antigravityAuthADC: + return "adc" + case antigravityAuthAPIKey: + return "gemini-api-key" + case antigravityAuthOAuth: + return "oauth" + } + return "oauth" +} + +func antigravityAuthModeFrom(env []string) antigravityAuthMode { + if antigravityHasADCCredentials(env) { + return antigravityAuthADC + } + if value, ok := antigravityEnvValue(env, geminiAPIKeyEnvKey); ok && strings.TrimSpace(value) != "" { + return antigravityAuthAPIKey + } + return antigravityAuthOAuth +} + +// antigravityUsesIsolatedHome reports whether agy runs with HOME redirected to +// the per-repo test home (every non-OAuth mode). Every path that peeks at agy's +// on-disk state must agree with this or it reads the wrong tree. +func antigravityUsesIsolatedHome(env []string) bool { + return antigravityAuthModeFrom(env) != antigravityAuthOAuth +} + +// 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 antigravityModel() string { + if model := os.Getenv("E2E_ANTIGRAVITY_MODEL"); model != "" { + return model + } + return antigravityDefaultModel +} + +func (a *Antigravity) Bootstrap() error { + return antigravityBootstrap(os.Environ()) +} + +func antigravityBootstrap(env []string) error { + credentialsPath, hasCredentials := antigravityEnvValue(env, googleCredentialsEnvKey) + if hasCredentials && credentialsPath != "" { + if _, err := os.Stat(credentialsPath); err != nil { + return fmt.Errorf("antigravity E2E GOOGLE_APPLICATION_CREDENTIALS %q is not readable: %w", credentialsPath, err) + } + return nil + } + if antigravityAuthModeFrom(env) == antigravityAuthAPIKey { + return nil + } + if antigravityInCI(env) { + return errors.New("antigravity E2E in CI requires GEMINI_API_KEY (agy API-key mode) or GOOGLE_APPLICATION_CREDENTIALS (ADC via the ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON GitHub secret)") + } + return nil +} + +func antigravityInCI(env []string) bool { + return antigravityTruthyEnv(env, "CI") || antigravityTruthyEnv(env, "GITHUB_ACTIONS") +} + +func antigravityTruthyEnv(env []string, key string) bool { + value, ok := antigravityEnvValue(env, key) + if !ok { + return false + } + value = strings.TrimSpace(strings.ToLower(value)) + return value != "" && value != "0" && value != "false" +} + +func (a *Antigravity) IsTransientError(out Output, err error) bool { + combined := out.Stdout + out.Stderr + if err != nil { + combined += "\n" + err.Error() + } + // Fatal configuration/entitlement walls win over every transient signal: + // agy wraps an exhausted individual quota in "overloaded"/"429", which the + // transient patterns would otherwise match, sending the harness into a + // futile scenario-restart loop against a wall that won't clear for days. + if _, fatal := antigravityFatalError(combined); fatal { + return false + } + if errors.Is(err, context.DeadlineExceeded) { + return true + } + return antigravityContainsTransient(combined) || antigravityRawToolCallOutput(combined) +} + +// antigravityFatalError detects non-retryable walls that no scenario restart +// will clear: an exhausted individual quota, a gated/disabled Code Assist +// backend, or a missing agy login. Returns a human-actionable message and true +// when one is present. Also matches its own generated messages so a fatal +// finding folded into an error stays fatal on reclassification. +// See reference_antigravity_auth_entitlement. +func antigravityFatalError(content string) (string, bool) { + switch { + case strings.Contains(content, "Individual quota reached"), + strings.Contains(content, "agy individual quota exhausted"): + return "agy individual quota exhausted (consumer tier resets on a rolling window) — use an entitled account/ADC or wait for the reset shown in the agy log", true + case strings.Contains(content, "SERVICE_DISABLED"), + strings.Contains(content, "AUTH_PERMISSION_DENIED"), + strings.Contains(content, "agy backend not provisioned"): + return "agy backend not provisioned for this project/identity (cloudcode-pa is gated) — needs a Gemini Code Assist subscription + seat, not just an enabled API", true + case strings.Contains(content, "GEMINI_API_KEY environment variable is not set"), + strings.Contains(content, "agy Gemini API key mode misconfigured"): + return "agy Gemini API key mode misconfigured: modelProvider is gemini but GEMINI_API_KEY is unset in the spawned env", true + case strings.Contains(content, "API_KEY_INVALID"), + strings.Contains(content, "API key not valid"), + strings.Contains(content, "agy rejected the Gemini API key"): + return "agy rejected the Gemini API key (API_KEY_INVALID) — check the GEMINI_API_KEY secret", true + case strings.Contains(content, "not logged into Antigravity"), + strings.Contains(content, "agy is not logged in"): + return "agy is not logged in — authenticate with `agy` interactively, set GEMINI_API_KEY, or provide ADC credentials", true + } + return "", false +} + +// antigravityFatalFromLogs scans agy CLI log files modified since `since` for +// fatal wall markers. Headless (-p) runs never surface the quota detail on +// stderr, and the transcript's ERROR_MESSAGE carries only the generic +// "overloaded" text — "Individual quota reached" lands solely in +// ~/.gemini/antigravity-cli/log/cli-*.log. Without this peek the harness +// classifies the quota wall as transient and retries into it. +// E2E_ANTIGRAVITY_LOG_DIR overrides the directory (tests). +func antigravityFatalFromLogs(since time.Time, repoDir string) (string, bool) { + dir := os.Getenv("E2E_ANTIGRAVITY_LOG_DIR") + if dir == "" { + home := antigravityHomeDir(repoDir) + if home == "" { + return "", false + } + dir = filepath.Join(home, ".gemini", "antigravity-cli", "log") + } + entries, err := os.ReadDir(dir) + if err != nil { + return "", false + } + for _, e := range entries { + if e.IsDir() || !strings.HasPrefix(e.Name(), "cli-") { + continue + } + info, infoErr := e.Info() + if infoErr != nil || info.ModTime().Before(since) { + continue + } + data, readErr := os.ReadFile(filepath.Join(dir, e.Name())) + if readErr != nil { + continue + } + if msg, fatal := antigravityFatalError(string(data)); fatal { + return msg, true + } + } + return "", false +} + +func antigravityContainsTransient(content string) bool { + transientPatterns := []string{ + "timed out waiting for response", + "429", + "RESOURCE_EXHAUSTED", + "UNAVAILABLE", + "DEADLINE_EXCEEDED", + "INTERNAL", + "503", + "ECONNRESET", + "ETIMEDOUT", + "overloaded", + "intermittent errors", + } + for _, p := range transientPatterns { + if strings.Contains(content, p) { + return true + } + } + return false +} + +func (a *Antigravity) RunPrompt(ctx context.Context, dir string, prompt string, opts ...Option) (Output, error) { + cfg := &runConfig{Model: antigravityModel()} + for _, o := range opts { + o(cfg) + } + + // Resolve the per-prompt deadline through the shared chain so E2E_TIMEOUT + // and WithPromptTimeout apply to agy like every other runner. + promptCtx, cancel, err := boundPrompt(ctx, 60*time.Second, cfg) + if err != nil { + return Output{}, err + } + defer cancel() + startedAt := time.Now().Add(-2 * time.Second) + + headAtStart := "" + if antigravityPromptRequestsCommit(prompt) { + headAtStart = antigravityGitHead(dir) + } + + out, err := a.runPromptOnce(promptCtx, dir, prompt, cfg.Model) + if err == nil && antigravityShouldRetryMissingCommit(prompt, dir, headAtStart) { + err = errors.New("antigravity did not create requested commit; HEAD unchanged") + } + + if msg, ok := antigravityPromptTranscriptTransient(antigravityBrainDir(dir), prompt, startedAt); ok { + if err == nil { + err = errors.New(msg) + } else { + err = fmt.Errorf("%w: %s", err, msg) + } + } + + // Surface fatal config/entitlement walls as a clear, actionable error so the + // test fails fast with a legible reason instead of a generic timeout/restart. + if msg, fatal := antigravityFatalError(out.Stdout + out.Stderr); fatal { + if err == nil { + err = errors.New(msg) + } else { + err = fmt.Errorf("%s (%w)", msg, err) + } + } else if err != nil { + // The stdout/stderr surface can be clean while the wall is only in + // agy's CLI log (headless quota exhaustion). Peek before letting the + // error be classified transient. + if logMsg, fatal := antigravityFatalFromLogs(startedAt, dir); fatal { + err = fmt.Errorf("%s (%w)", logMsg, err) + } + } + + return out, err +} + +func (a *Antigravity) runPromptOnce(ctx context.Context, dir string, prompt string, model string) (Output, error) { + // 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, displayArgs := antigravityPromptArgs(prompt, dir, model) + + if err := antigravityPrepareHome(os.Environ(), dir); err != nil { + return Output{Command: a.Binary() + " " + strings.Join(displayArgs, " ")}, err + } + + cmd := exec.CommandContext(ctx, a.Binary(), args...) + cmd.Dir = dir + cmd.Stdin = nil + cmd.Env = antigravityPromptEnv(dir) + 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 ctx.Err() == context.DeadlineExceeded { + err = fmt.Errorf("%w: %w", err, context.DeadlineExceeded) + } + } + if err == nil && antigravityAuthenticationRequired(stdout.String()+"\n"+stderr.String()) { + err = errors.New("antigravity authentication required or timed out") + } + if err == nil && antigravityRawToolCallOutput(stdout.String()) { + err = errors.New("antigravity emitted raw tool call output") + } + + 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()) + + if err := antigravityPrepareHome(os.Environ(), dir); err != nil { + return nil, err + } + envArgs, unsetEnv := antigravitySessionEnv(os.Environ(), dir) + + args := append([]string{"env"}, envArgs...) + args = append(args, antigravitySessionCLIArgsFromEnv(dir, antigravityModel(), os.Environ())...) + s, err := NewTmuxSession(name, dir, unsetEnv, 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|Terms of Service|color scheme)`, 30*time.Second) + if err != nil { + _ = s.Close() + return nil, fmt.Errorf("waiting for startup prompt: %w", err) + } + // First-run onboarding, in case the seeded cache/onboarding.json stops + // being honoured: the Terms screen's Enter only toggles its checkbox, + // so move to [Done] (Down to the button row, Right to Done) first. + if antigravityOnTermsScreen(content) { + _ = s.SendKeys("Down") + time.Sleep(200 * time.Millisecond) + _ = s.SendKeys("Right") + time.Sleep(200 * time.Millisecond) + _ = s.SendKeys("Enter") + time.Sleep(500 * time.Millisecond) + continue + } + if antigravityNeedsStartupConfirmation(content) { + _ = s.SendKeys("Enter") + time.Sleep(500 * time.Millisecond) + continue + } + if antigravityReadyForPrompt(content) { + break + } + _ = s.SendKeys("Enter") + time.Sleep(500 * time.Millisecond) + } + s.stableAtSend = "" + + return &antigravityInteractiveSession{Session: s, dir: dir}, nil +} + +type antigravityInteractiveSession struct { + Session + + dir string + lastInput string + headAtSend string + commitRequested bool + commitRetried bool +} + +func (s *antigravityInteractiveSession) Send(input string) error { + s.lastInput = input + s.commitRequested = antigravityPromptRequestsCommit(input) + s.commitRetried = false + s.headAtSend = "" + if s.commitRequested { + s.headAtSend = antigravityGitHead(s.dir) + } + return s.Session.Send(antigravityWorkspacePrompt(input, s.dir)) +} + +func (s *antigravityInteractiveSession) WaitFor(pattern string, timeout time.Duration) (string, error) { + var content string + var err error + for range 3 { + content, err = s.Session.WaitFor(pattern, timeout) + if err != nil || s.lastInput == "" { + return content, err + } + if antigravityRawToolCallOutput(content) { + if sendErr := s.Session.Send(antigravityWorkspacePrompt(s.lastInput, s.dir)); sendErr != nil { + return content, sendErr + } + continue + } + if s.shouldRetryMissingCommit() { + s.commitRetried = true + followup := antigravityMissingCommitPrompt(s.lastInput) + if sendErr := s.Session.Send(antigravityWorkspacePrompt(followup, s.dir)); sendErr != nil { + return content, sendErr + } + continue + } + return content, nil + } + // Retries exhausted. If the final response is still raw tool-call output, + // returning it with a nil error would report a malformed turn as success + // (and the test would fail later on an unrelated assertion). Surface it as + // a failure — IsTransientError recognizes this message and restarts the + // scenario. + if antigravityRawToolCallOutput(content) { + return content, errors.New("antigravity emitted raw tool call output after retries") + } + return content, err +} + +func (s *antigravityInteractiveSession) shouldRetryMissingCommit() bool { + return s.commitRequested && !s.commitRetried && s.headAtSend != "" && antigravityGitHead(s.dir) == s.headAtSend +} + +func antigravityPromptRequestsCommit(input string) bool { + lower := strings.ToLower(input) + if strings.Contains(lower, "do not commit") || strings.Contains(lower, "don't commit") || strings.Contains(lower, "dont commit") { + return false + } + return strings.Contains(lower, "commit") +} + +func antigravityShouldRetryMissingCommit(prompt string, dir string, headAtStart string) bool { + return antigravityPromptRequestsCommit(prompt) && headAtStart != "" && antigravityGitHead(dir) == headAtStart +} + +func antigravityGitHead(dir string) string { + cmd := exec.Command("git", "-C", dir, "rev-parse", "HEAD") + out, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func antigravityMissingCommitPrompt(previous string) string { + return "The previous request asked for a git commit, but HEAD has not changed. Complete the previous request now by staging the changed files and running git commit with an appropriate message. Do not modify repository file contents in this follow-up; only run git status, git add, and git commit as needed. Do not respond until the git commit command exits successfully.\n\nPrevious request:\n" + previous +} + +func antigravitySessionCLIArgsFromEnv(dir, model string, env []string) []string { + if model == "" { + model = antigravityDefaultModel + } + args := []string{ + antigravityBinary, + "--model", model, + "--dangerously-skip-permissions", + "--new-project", + "--add-dir", dir, + } + if projectID := antigravityProjectID(env); projectID != "" { + args = append(args, "--project", projectID) + } + return args +} + +func antigravityPromptArgs(prompt, dir, model string) ([]string, []string) { + return antigravityPromptArgsFromEnv(prompt, dir, model, os.Environ()) +} + +func antigravityPromptArgsFromEnv(prompt, dir, model string, env []string) ([]string, []string) { + if model == "" { + model = antigravityDefaultModel + } + workspacePrompt := antigravityWorkspacePrompt(prompt, dir) + args := []string{ + "-p", workspacePrompt, + "--model", model, + "--dangerously-skip-permissions", + "--new-project", + "--add-dir", dir, + } + displayArgs := []string{ + "-p", fmt.Sprintf("%q", workspacePrompt), + "--model", model, + "--dangerously-skip-permissions", + "--new-project", + "--add-dir", dir, + } + if projectID := antigravityProjectID(env); projectID != "" { + args = append(args, "--project", projectID) + displayArgs = append(displayArgs, "--project", projectID) + } + return args, displayArgs +} + +func antigravityWorkspacePrompt(prompt, dir string) string { + return fmt.Sprintf("Use the workspace at %s. Resolve any relative file paths in the request relative to that workspace, and write files inside that workspace unless the request says otherwise. Complete every requested operation before responding, including every git command or commit mentioned in the request. If the request asks for a commit, run a shell command such as git add and git commit; editing files is not a completed commit. Do not claim a file was committed until the git command has completed successfully. Do not run verification commands such as list_dir or view_file unless requested. If the request has numbered steps, complete every numbered step in order. For multi-step requests with file contents and git commands, use shell commands when that is the most direct way to preserve order. Do not create artifacts for repository files; create or edit files in the workspace. After completing the requested change, do not run extra checks or commands; immediately respond with a short confirmation.\n\nRequest:\n%s", dir, prompt) +} + +// antigravityHookProbeEnvKey turns on the hook probe (see PrepareRepo). It is +// on by default in CI, where artifacts are the only evidence a run leaves. +const antigravityHookProbeEnvKey = "E2E_ANTIGRAVITY_HOOK_PROBE" + +// antigravityHookProbeLog is where the probe hook records each hook firing, +// under .entire/logs so captureEntireLogs picks it up with the CLI logs. +const antigravityHookProbeLog = ".entire/logs/agy-hook-probe.log" + +func antigravityHookProbeEnabled(env []string) bool { + // The probe is an sh script. agy hands hook commands to cmd.exe on Windows, + // where it fails with "The filename, directory name, or volume label syntax + // is incorrect" — and a failing PreToolUse hook makes agy refuse the tool + // call, so the probe would block the very edit the test waits for + // (observed on the first windows-latest run). The Entire hooks' own log + // lines are the evidence that agy runs hooks there. + if runtime.GOOS == "windows" { + return false + } + if value, ok := antigravityEnvValue(env, antigravityHookProbeEnvKey); ok { + return antigravityTruthyEnv(env, antigravityHookProbeEnvKey) && value != "" + } + return antigravityInCI(env) +} + +// PrepareRepo runs after `entire enable`. When the hook probe is enabled it +// adds a second, independent entry to the repo's .agents/hooks.json that +// appends the hook's cwd and stdin payload to antigravityHookProbeLog for +// PreInvocation, PreToolUse and Stop. Entire's own entry is untouched. This +// answers "did agy run hooks at all, from where, with what payload" when a run +// leaves no session state — the failure mode that is otherwise invisible. +func (a *Antigravity) PrepareRepo(repoDir string) error { + return antigravityPrepareRepo(os.Environ(), repoDir) +} + +func antigravityPrepareRepo(env []string, repoDir string) error { + if !antigravityHookProbeEnabled(env) { + return nil + } + hooksPath := filepath.Join(repoDir, ".agents", "hooks.json") + data, err := os.ReadFile(hooksPath) + if err != nil { + return fmt.Errorf("antigravity E2E: read %s: %w", hooksPath, err) + } + var hooks map[string]json.RawMessage + if err := json.Unmarshal(data, &hooks); err != nil { + return fmt.Errorf("antigravity E2E: parse %s: %w", hooksPath, err) + } + logPath := filepath.Join(repoDir, antigravityHookProbeLog) + if err := os.MkdirAll(filepath.Dir(logPath), 0o750); err != nil { + return fmt.Errorf("antigravity E2E: create probe log dir: %w", err) + } + // Format starts with %s: a leading "---" would be parsed by printf as an option. + probeCmd := fmt.Sprintf(`sh -c 'printf "%%s cwd=%%s\n" "--- $(date +%%T)" "$(pwd)" >> %q; cat >> %q; printf "\n" >> %q'`, logPath, logPath, logPath) + probe := map[string]any{ + "PreInvocation": []map[string]any{{"type": "command", "command": probeCmd}}, + "PreToolUse": []map[string]any{{"matcher": "*", "hooks": []map[string]any{{"type": "command", "command": probeCmd}}}}, + "Stop": []map[string]any{{"type": "command", "command": probeCmd}}, + } + raw, err := json.Marshal(probe) + if err != nil { + return fmt.Errorf("antigravity E2E: encode probe hooks: %w", err) + } + hooks["entire-e2e-probe"] = raw + out, err := json.MarshalIndent(hooks, "", " ") + if err != nil { + return fmt.Errorf("antigravity E2E: encode hooks.json: %w", err) + } + return os.WriteFile(hooksPath, append(out, '\n'), 0o600) +} + +// CleanupRepo removes the isolated HOME that antigravityPrepareHome created +// beside the repo. It has no *testing.T of its own, so the repo's cleanup calls +// this; RemoveAll on the OAuth path, where no such directory exists, is a no-op. +func (a *Antigravity) CleanupRepo(repoDir string) error { + if err := os.RemoveAll(antigravityTestHomeDir(repoDir)); err != nil { + return fmt.Errorf("antigravity E2E: remove isolated home: %w", err) + } + return nil +} + +// ExtraArtifacts exposes agy's own state from the isolated HOME (its CLI log +// directory and the settings.json the harness wrote) so CI failures inside +// agy are diagnosable from artifacts. Nothing is captured in OAuth mode: the +// developer's real HOME is not a test artifact. +func (a *Antigravity) ExtraArtifacts(repoDir string) map[string]string { + return antigravityExtraArtifacts(os.Environ(), repoDir) +} + +func antigravityExtraArtifacts(env []string, repoDir string) map[string]string { + if !antigravityUsesIsolatedHome(env) { + return nil + } + cli := filepath.Join(antigravityTestHomeDir(repoDir), ".gemini", "antigravity-cli") + return map[string]string{ + "agy-home-logs": filepath.Join(cli, "log"), + "agy-home-settings.json": filepath.Join(cli, "settings.json"), + } +} + +func antigravityTestHomeDir(repoDir string) string { + return filepath.Join(filepath.Dir(repoDir), filepath.Base(repoDir)+"-antigravity-home") +} + +// antigravityHomeDir resolves the HOME the agy subprocess actually ran with: +// the per-repo isolated test home in ADC and API-key mode +// (antigravityPromptEnvFrom sets HOME=antigravityTestHomeDir), the developer's +// real HOME otherwise. Every path that peeks at agy's on-disk state (brain, +// CLI logs) must go through this, or isolated-home runs read the wrong tree. +func antigravityHomeDir(repoDir string) string { + if antigravityUsesIsolatedHome(os.Environ()) { + return antigravityTestHomeDir(repoDir) + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return homeDir +} + +func antigravityBrainDir(repoDir string) string { + homeDir := antigravityHomeDir(repoDir) + if homeDir == "" { + return "" + } + return filepath.Join(homeDir, ".gemini", "antigravity-cli", "brain") +} + +func antigravityPromptTranscriptTransient(brainDir, prompt string, since time.Time) (string, bool) { + if brainDir == "" || prompt == "" { + return "", false + } + for _, path := range antigravityTranscriptCandidates(brainDir) { + info, err := os.Stat(path) + if err != nil || info.ModTime().Before(since) { + continue + } + if msg, ok := antigravityTranscriptFileTransient(path, prompt); ok { + return msg, true + } + } + return "", false +} + +func antigravityTranscriptCandidates(brainDir string) []string { + var candidates []string + for _, name := range []string{"transcript_full.jsonl", "transcript.jsonl"} { + matches, err := filepath.Glob(filepath.Join(brainDir, "*", ".system_generated", "logs", name)) + if err == nil { + candidates = append(candidates, matches...) + } + } + return candidates +} + +func antigravityTranscriptFileTransient(path, prompt string) (string, bool) { + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + + var sawPrompt bool + var transient string + for _, line := range strings.Split(string(data), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var step struct { + Type string `json:"type"` + Content string `json:"content"` + Error string `json:"error"` + ErrorCode int `json:"error_code"` + } + if err := json.Unmarshal([]byte(line), &step); err != nil { + continue + } + if step.Type == "USER_INPUT" && strings.Contains(step.Content, prompt) { + sawPrompt = true + } + if step.Type == "ERROR_MESSAGE" { + msg := fmt.Sprintf("error_code=%d: %s", step.ErrorCode, step.Error) + if step.ErrorCode == 429 || antigravityContainsTransient(msg) { + transient = "antigravity transcript contains ERROR_MESSAGE " + msg + } + } + } + return transient, sawPrompt && transient != "" +} + +func antigravityPromptEnv(repoDir string) []string { + return antigravityPromptEnvFrom(os.Environ(), repoDir) +} + +// antigravityPromptEnvFrom returns the env for spawning agy in print mode. +// Antigravity's OAuth state lives under HOME/.gemini, so both credential modes +// isolate HOME to the per-repo test home so no browser/keyring flow can start: +// - ADC: USE_ADC=1 plus the ADC project; API keys are scrubbed so agy's +// default backend is used. +// - GEMINI_API_KEY: the key passes through (GOOGLE_GEMINI_BASE_URL too); +// GOOGLE_API_KEY is scrubbed (agy would prefer it), and ADC-only knobs are +// dropped. antigravityPrepareHome writes the modelProvider setting the +// mode requires. +// +// With neither, the developer's real HOME is kept for local OAuth E2E. +func antigravityPromptEnvFrom(base []string, repoDir string) []string { + switch antigravityAuthModeFrom(base) { + case antigravityAuthADC: + env := append( + filterEnv(base, "ENTIRE_TEST_TTY", "ACCESSIBLE", "HOME", "USERPROFILE", antigravityADCEnvKey, googleCloudProjectEnvKey, + geminiAPIKeyEnvKey, googleAPIKeyEnvKey), + "ACCESSIBLE=1", + antigravityADCEnvKey+"=1", + ) + env = append(env, antigravityIsolatedHomeEnv(repoDir)...) + if projectID := antigravityProjectID(base); projectID != "" { + env = append(env, googleCloudProjectEnvKey+"="+projectID) + } + return env + case antigravityAuthAPIKey: + env := append( + filterEnv(base, "ENTIRE_TEST_TTY", "ACCESSIBLE", "HOME", "USERPROFILE", antigravityADCEnvKey, googleCloudProjectEnvKey, googleAPIKeyEnvKey), + "ACCESSIBLE=1", + ) + return append(env, antigravityIsolatedHomeEnv(repoDir)...) + case antigravityAuthOAuth: + return append(filterEnv(base, "ENTIRE_TEST_TTY", "ACCESSIBLE"), "ACCESSIBLE=1") + } + return append(filterEnv(base, "ENTIRE_TEST_TTY", "ACCESSIBLE"), "ACCESSIBLE=1") +} + +// antigravityIsolatedHomeEnv points agy AND the entire hooks it spawns at the +// per-repo test home. HOME is what agy and every Unix caller read; on Windows +// os.UserHomeDir (which the CLI uses to find agy's brain directory) reads +// USERPROFILE instead, so both are redirected there or the two sides would +// disagree about where the transcript lives. +func antigravityIsolatedHomeEnv(repoDir string) []string { + home := antigravityTestHomeDir(repoDir) + env := []string{"HOME=" + home} + if runtime.GOOS == "windows" { + env = append(env, "USERPROFILE="+home) + } + return env +} + +// antigravityPrepareHome makes the isolated test home ready for the resolved +// auth mode before agy is spawned. No-op for OAuth (developer's real HOME). +// For both isolated modes it pre-trusts the test repo: agy only loads a +// workspace's .agents/hooks.json for a trusted workspace, and a fresh HOME +// trusts nothing — headless `-p` then silently runs with no hooks at all +// (observed in CI: agy created the file, git hooks fired, no agy hook did). +// This is agy's equivalent of gemini-cli's GEMINI_CLI_TRUST_WORKSPACE=true. +// API-key mode additionally needs modelProvider "gemini", without which agy +// refuses to start. +func antigravityPrepareHome(env []string, repoDir string) error { + mode := antigravityAuthModeFrom(env) + if mode == antigravityAuthOAuth { + return nil + } + return antigravityEnsureIsolatedHome(repoDir, mode == antigravityAuthAPIKey) +} + +// antigravityEnsureIsolatedHome writes the isolated home's agy settings.json: +// trustedWorkspaces gains the test repo (given and symlink-resolved forms, +// since agy compares resolved paths and macOS /var is a symlink), and with +// apiKey set, modelProvider becomes "gemini". Other keys are preserved (agy +// itself writes into this file once it runs). Idempotent. +func antigravityEnsureIsolatedHome(repoDir string, apiKey bool) error { + dir := filepath.Join(antigravityTestHomeDir(repoDir), ".gemini", "antigravity-cli") + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("antigravity E2E: create isolated agy home: %w", err) + } + if err := antigravitySeedOnboarding(dir); err != nil { + return err + } + settingsPath := filepath.Join(dir, "settings.json") + settings := map[string]json.RawMessage{} + if data, err := os.ReadFile(settingsPath); err == nil { + if len(strings.TrimSpace(string(data))) > 0 { + if err := json.Unmarshal(data, &settings); err != nil { + return fmt.Errorf("antigravity E2E: parse %s: %w", settingsPath, err) + } + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("antigravity E2E: read %s: %w", settingsPath, err) + } + + changed := false + if apiKey && string(settings["modelProvider"]) != `"gemini"` { + settings["modelProvider"] = json.RawMessage(`"gemini"`) + changed = true + } + var trusted []string + if raw, ok := settings["trustedWorkspaces"]; ok { + _ = json.Unmarshal(raw, &trusted) // unparseable list is rebuilt below + } + for _, want := range antigravityTrustPaths(repoDir) { + if !slices.Contains(trusted, want) { + trusted = append(trusted, want) + changed = true + } + } + if changed { + raw, err := json.Marshal(trusted) + if err != nil { + return fmt.Errorf("antigravity E2E: encode trustedWorkspaces: %w", err) + } + settings["trustedWorkspaces"] = raw + } + if !changed { + return nil + } + out, err := json.MarshalIndent(settings, "", " ") + if err != nil { + return fmt.Errorf("antigravity E2E: encode settings: %w", err) + } + if err := os.WriteFile(settingsPath, append(out, '\n'), 0o600); err != nil { + return fmt.Errorf("antigravity E2E: write %s: %w", settingsPath, err) + } + return nil +} + +// antigravityOnboardingCache is the file agy 1.2.x reads to decide whether to +// run its first-run onboarding (relative to the agy config dir), and the +// content it writes once a user has clicked through. +const antigravityOnboardingCache = "cache/onboarding.json" + +const antigravityOnboardingComplete = `{ + "consumerOnboardingComplete": true, + "enterpriseOnboardingComplete": false, + "onboardingComplete": true +} +` + +// antigravitySeedOnboarding marks a fresh isolated HOME as already onboarded. +// Interactive agy in a HOME that has never run shows two screens before the +// prompt — "Choose your color scheme" and a Terms of Service & Data Use +// consent — and neither is answered by Enter alone (the consent's Enter only +// toggles its checkbox). Headless `-p` runs skip them, so this only ever bit +// the tmux-driven tests, and only in the isolated-HOME modes: every +// TestInteractive* leg failed in CI with agy sat on the Terms screen. The +// state lives in cache/onboarding.json (established by completing onboarding +// in a scratch HOME and diffing), not in settings.json or jetski_state.pbtxt. +// Idempotent; an existing file is left alone. +func antigravitySeedOnboarding(agyDir string) error { + path := filepath.Join(agyDir, filepath.FromSlash(antigravityOnboardingCache)) + if _, err := os.Stat(path); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("antigravity E2E: stat %s: %w", path, err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return fmt.Errorf("antigravity E2E: create %s: %w", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(antigravityOnboardingComplete), 0o600); err != nil { + return fmt.Errorf("antigravity E2E: write %s: %w", path, err) + } + return nil +} + +// antigravityTrustPaths returns the workspace paths to trust for repoDir: as +// given and symlink-resolved (deduplicated). +func antigravityTrustPaths(repoDir string) []string { + paths := []string{repoDir} + if resolved, err := filepath.EvalSymlinks(repoDir); err == nil && resolved != repoDir { + paths = append(paths, resolved) + } + return paths +} + +func antigravitySessionEnv(base []string, repoDir string) ([]string, []string) { + envArgs := []string{"ACCESSIBLE=1"} + unsetEnv := []string{"CI", "GITHUB_ACTIONS", "ENTIRE_TEST_TTY"} + if term, ok := antigravityEnvValue(base, "TERM"); ok && term != "" { + envArgs = append(envArgs, "TERM="+term) + } + switch antigravityAuthModeFrom(base) { + case antigravityAuthADC: + envArgs = append(envArgs, antigravityADCEnvKey+"=1", "HOME="+antigravityTestHomeDir(repoDir)) + unsetEnv = append(unsetEnv, "HOME", antigravityADCEnvKey, geminiAPIKeyEnvKey, googleAPIKeyEnvKey) + if projectID := antigravityProjectID(base); projectID != "" { + envArgs = append(envArgs, googleCloudProjectEnvKey+"="+projectID) + unsetEnv = append(unsetEnv, googleCloudProjectEnvKey) + } + case antigravityAuthAPIKey: + envArgs = append(envArgs, "HOME="+antigravityTestHomeDir(repoDir)) + unsetEnv = append(unsetEnv, "HOME", antigravityADCEnvKey, googleCloudProjectEnvKey, googleAPIKeyEnvKey) + case antigravityAuthOAuth: + // Developer's real HOME and login; nothing to redirect. + } + return envArgs, unsetEnv +} + +// antigravityOnTermsScreen reports agy's first-run Terms of Service & Data +// Use consent, whose Enter toggles the checkbox rather than continuing. +func antigravityOnTermsScreen(content string) bool { + return strings.Contains(content, "Terms of Service") +} + +// antigravityNeedsStartupConfirmation reports a startup screen that Enter +// dismisses. The color-scheme chooser is one (Enter takes the highlighted +// default); it also contains a `>` selection marker, which is why it must be +// listed here rather than read as the prompt. +func antigravityNeedsStartupConfirmation(content string) bool { + confirmationMarkers := []string{ + "Do you trust the contents of this project?", + "Yes, I trust this folder", + "Enter to select", + "Enter to confirm", + "Acknowledge", + "Choose your color scheme", + "Welcome to Antigravity CLI!", + } + for _, marker := range confirmationMarkers { + if strings.Contains(content, marker) { + return true + } + } + return false +} + +func antigravityReadyForPrompt(content string) bool { + return strings.Contains(content, ">") && !antigravityNeedsStartupConfirmation(content) && !antigravityOnTermsScreen(content) +} + +func antigravityAuthenticationRequired(content string) bool { + content = strings.ToLower(content) + return strings.Contains(content, "authentication required") || + strings.Contains(content, "authentication timed out") +} + +func antigravityRawToolCallOutput(content string) bool { + hasRawChannel := strings.Contains(content, "<|start|>assistant") || + strings.Contains(content, "<|channel|>") + hasToolPayload := strings.Contains(content, "to=functions.") || + strings.Contains(content, `"CommandLine"`) || + strings.Contains(content, `"toolAction"`) + return hasRawChannel && strings.Contains(content, "<|message|>") && hasToolPayload +} + +func antigravityHasADCCredentials(env []string) bool { + value, ok := antigravityEnvValue(env, googleCredentialsEnvKey) + return ok && value != "" +} + +func antigravityProjectID(env []string) string { + for _, key := range []string{antigravityProjectEnvKey, googleCloudProjectEnvKey} { + if value, ok := antigravityEnvValue(env, key); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return antigravityProjectIDFromADC(env) +} + +func antigravityProjectIDFromADC(env []string) string { + credentialsPath, ok := antigravityEnvValue(env, googleCredentialsEnvKey) + if !ok || credentialsPath == "" { + return "" + } + data, err := os.ReadFile(credentialsPath) + if err != nil { + return "" + } + var credentials struct { + ProjectID string `json:"project_id"` + } + if err := json.Unmarshal(data, &credentials); err != nil { + return "" + } + return strings.TrimSpace(credentials.ProjectID) +} + +func antigravityEnvValue(env []string, key string) (string, bool) { + prefix := key + "=" + for i := len(env) - 1; i >= 0; i-- { + if strings.HasPrefix(env[i], prefix) { + return strings.TrimPrefix(env[i], prefix), true + } + } + return "", false +} diff --git a/e2e/agents/antigravity_probe_test.go b/e2e/agents/antigravity_probe_test.go new file mode 100644 index 0000000000..5510766516 --- /dev/null +++ b/e2e/agents/antigravity_probe_test.go @@ -0,0 +1,55 @@ +package agents + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestAntigravityProbeCommandRunsUnderSh pins that the generated probe command +// runs under sh (as agy runs hook commands) and records header, cwd and stdin. +func TestAntigravityProbeCommandRunsUnderSh(t *testing.T) { + repoDir := t.TempDir() + hooksPath := filepath.Join(repoDir, ".agents", "hooks.json") + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(hooksPath, []byte(`{"entire":{}}`), 0o600); err != nil { + t.Fatal(err) + } + if err := antigravityPrepareRepo([]string{"CI=true"}, repoDir); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(hooksPath) + var hooks map[string]struct { + PreInvocation []struct { + Command string `json:"command"` + } `json:"PreInvocation"` + } + if err := json.Unmarshal(data, &hooks); err != nil { + t.Fatal(err) + } + cmdStr := hooks["entire-e2e-probe"].PreInvocation[0].Command + // agy executes the command string via a shell; emulate that. + cmd := exec.Command("sh", "-c", cmdStr) + cmd.Dir = filepath.Join(repoDir, ".agents") + cmd.Stdin = strings.NewReader(`{"conversationId":"c1","workspacePaths":["` + repoDir + `"]}`) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("probe cmd failed: %v\n%s\ncmd=%s", err, out, cmdStr) + } + logData, err := os.ReadFile(filepath.Join(repoDir, antigravityHookProbeLog)) + if err != nil { + t.Fatalf("probe log missing: %v", err) + } + s := string(logData) + // pwd may report the path as given or symlink-resolved (macOS /var → /private/var). + agentsDir := filepath.Join(repoDir, ".agents") + resolvedDir, _ := filepath.EvalSymlinks(agentsDir) + hasCwd := strings.Contains(s, "cwd="+agentsDir) || strings.Contains(s, "cwd="+resolvedDir) + if !strings.Contains(s, "--- ") || !hasCwd || !strings.Contains(s, `"conversationId":"c1"`) { + t.Fatalf("probe log content unexpected:\n%s\ncmd=%s", s, cmdStr) + } +} diff --git a/e2e/agents/antigravity_test.go b/e2e/agents/antigravity_test.go new file mode 100644 index 0000000000..7b91a85b09 --- /dev/null +++ b/e2e/agents/antigravity_test.go @@ -0,0 +1,1061 @@ +package agents + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +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()) + } +} + +func TestAntigravityDefaultConcurrencyIsSerial(t *testing.T) { + t.Parallel() + + if antigravityDefaultConcurrency != 1 { + t.Fatalf("antigravityDefaultConcurrency = %d, want 1", antigravityDefaultConcurrency) + } +} + +func TestAntigravityModelUsesDefault(t *testing.T) { + t.Setenv("E2E_ANTIGRAVITY_MODEL", "") + + if got := antigravityModel(); got != antigravityDefaultModel { + t.Fatalf("antigravityModel() = %q, want the default slug %q", got, antigravityDefaultModel) + } +} + +func TestAntigravityModelReadsEnv(t *testing.T) { + t.Setenv("E2E_ANTIGRAVITY_MODEL", "Gemini 3.1 Pro (Low)") + + if got := antigravityModel(); got != "Gemini 3.1 Pro (Low)" { + t.Fatalf("antigravityModel() = %q, want env override", got) + } +} + +func TestAntigravityBootstrapRequiresCredentialsInCI(t *testing.T) { + t.Setenv("CI", "true") + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + t.Setenv("GEMINI_API_KEY", "") + + err := (&Antigravity{}).Bootstrap() + if err == nil { + t.Fatal("Bootstrap() error = nil, want missing credentials error") + } + if !strings.Contains(err.Error(), "GEMINI_API_KEY") || !strings.Contains(err.Error(), "ANTIGRAVITY_GOOGLE_APPLICATION_CREDENTIALS_JSON") { + t.Fatalf("Bootstrap() error = %q, want guidance naming both GEMINI_API_KEY and the ADC secret", err) + } +} + +func TestAntigravityBootstrapAcceptsReadableADCFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "credentials.json") + if err := os.WriteFile(path, []byte(`{"type":"service_account"}`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("CI", "true") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", path) + + if err := (&Antigravity{}).Bootstrap(); err != nil { + t.Fatalf("Bootstrap() error = %v, want nil", err) + } +} + +func TestAntigravityBootstrapRejectsUnreadableADCFile(t *testing.T) { + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", filepath.Join(t.TempDir(), "missing.json")) + + err := (&Antigravity{}).Bootstrap() + if err == nil { + t.Fatal("Bootstrap() error = nil, want missing credentials file error") + } + if !strings.Contains(err.Error(), "GOOGLE_APPLICATION_CREDENTIALS") { + t.Fatalf("Bootstrap() error = %q, want credentials path context", err) + } +} + +func TestAntigravityBootstrapAllowsLocalOAuthWithoutADC(t *testing.T) { + t.Setenv("CI", "") + t.Setenv("GITHUB_ACTIONS", "") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + t.Setenv("GEMINI_API_KEY", "") + + if err := (&Antigravity{}).Bootstrap(); err != nil { + t.Fatalf("Bootstrap() error = %v, want nil for local OAuth fallback", err) + } +} + +func TestAntigravityPromptArgsIncludeModelAndWorkspace(t *testing.T) { + t.Parallel() + + args, displayArgs := antigravityPromptArgsFromEnv("make a file", "/repo", "gemini-2.5-pro", nil) + + if len(args) < 2 || !strings.Contains(args[1], "make a file") || !strings.Contains(args[1], "/repo") { + t.Fatalf("prompt arg = %#v, want workspace-scoped prompt containing original request and repo dir", args) + } + want := []string{ + "-p", args[1], + "--model", "gemini-2.5-pro", + "--dangerously-skip-permissions", + "--new-project", + "--add-dir", "/repo", + } + if !slices.Equal(args, want) { + t.Fatalf("args = %#v, want %#v", args, want) + } + if !slices.Contains(displayArgs, "--model") || !slices.Contains(displayArgs, "gemini-2.5-pro") { + t.Fatalf("displayArgs should include model flag, got %#v", displayArgs) + } +} + +func TestAntigravityWorkspacePromptPreservesRequestedGitOperations(t *testing.T) { + t.Parallel() + + prompt := antigravityWorkspacePrompt("create docs/red.md, then git add and commit it", "/repo") + + required := []string{ + "Complete every requested operation before responding", + "including every git command or commit mentioned in the request", + "If the request asks for a commit, run a shell command such as git add", + "Do not claim a file was committed until the git command has completed successfully", + "Do not run verification commands such as list_dir or view_file unless requested", + "If the request has numbered steps, complete every numbered step in order", + "For multi-step requests with file contents and git commands, use shell commands when that is the most direct way to preserve order", + "Do not create artifacts for repository files", + } + for _, want := range required { + if !strings.Contains(prompt, want) { + t.Fatalf("workspace prompt missing %q:\n%s", want, prompt) + } + } +} + +func TestAntigravityPromptArgsIncludeProjectFromADCJSON(t *testing.T) { + t.Parallel() + + credentialsPath := filepath.Join(t.TempDir(), "credentials.json") + if err := os.WriteFile(credentialsPath, []byte(`{"project_id":"entire-e2e"}`), 0o600); err != nil { + t.Fatal(err) + } + + args, displayArgs := antigravityPromptArgsFromEnv("make a file", "/repo", "gemini-2.5-pro", []string{ + "GOOGLE_APPLICATION_CREDENTIALS=" + credentialsPath, + "GOOGLE_CLOUD_PROJECT=", + }) + + projectFlag := slices.Index(args, "--project") + if projectFlag == -1 || projectFlag+1 >= len(args) || args[projectFlag+1] != "entire-e2e" { + t.Fatalf("args = %#v, want --project entire-e2e from ADC JSON", args) + } + if !slices.Contains(displayArgs, "--project") || !slices.Contains(displayArgs, "entire-e2e") { + t.Fatalf("displayArgs should include project flag, got %#v", displayArgs) + } +} + +func TestAntigravityPromptEnvUsesADCWithIsolatedHomeWhenCredentialsProvided(t *testing.T) { + t.Parallel() + + base := []string{ + "PATH=/bin", + "ENTIRE_TEST_TTY=1", + "HOME=/real-home", + "USE_ADC=0", + "GOOGLE_APPLICATION_CREDENTIALS=/creds.json", + } + + got := antigravityPromptEnvFrom(base, "/tmp/repo") + + if _, ok := envValue(got, "ENTIRE_TEST_TTY"); ok { + t.Fatalf("ENTIRE_TEST_TTY should be stripped, env=%#v", got) + } + if gotHome, _ := envValue(got, "HOME"); gotHome != "/tmp/repo-antigravity-home" { + t.Fatalf("HOME = %q, want isolated test home", gotHome) + } + if gotADC, _ := envValue(got, "USE_ADC"); gotADC != "1" { + t.Fatalf("USE_ADC = %q, want 1", gotADC) + } + if gotCreds, _ := envValue(got, "GOOGLE_APPLICATION_CREDENTIALS"); gotCreds != "/creds.json" { + t.Fatalf("GOOGLE_APPLICATION_CREDENTIALS = %q, want preserved credentials path", gotCreds) + } + if countEnv(got, "HOME") != 1 || countEnv(got, "USE_ADC") != 1 { + t.Fatalf("HOME and USE_ADC should be upserted once, env=%#v", got) + } +} + +func TestAntigravityPromptEnvSetsGoogleCloudProjectFromADCJSON(t *testing.T) { + t.Parallel() + + credentialsPath := filepath.Join(t.TempDir(), "credentials.json") + if err := os.WriteFile(credentialsPath, []byte(`{"project_id":"entire-e2e"}`), 0o600); err != nil { + t.Fatal(err) + } + base := []string{ + "PATH=/bin", + "HOME=/real-home", + "GOOGLE_APPLICATION_CREDENTIALS=" + credentialsPath, + } + + got := antigravityPromptEnvFrom(base, "/tmp/repo") + + if gotProject, _ := envValue(got, "GOOGLE_CLOUD_PROJECT"); gotProject != "entire-e2e" { + t.Fatalf("GOOGLE_CLOUD_PROJECT = %q, want project_id from ADC JSON", gotProject) + } +} + +func TestAntigravitySessionEnvSetsGoogleCloudProjectFromADCJSON(t *testing.T) { + t.Parallel() + + credentialsPath := filepath.Join(t.TempDir(), "credentials.json") + if err := os.WriteFile(credentialsPath, []byte(`{"project_id":"entire-e2e"}`), 0o600); err != nil { + t.Fatal(err) + } + base := []string{ + "PATH=/bin", + "HOME=/real-home", + "GOOGLE_APPLICATION_CREDENTIALS=" + credentialsPath, + } + + envArgs, _ := antigravitySessionEnv(base, "/tmp/repo") + + if gotProject, _ := envValue(envArgs, "GOOGLE_CLOUD_PROJECT"); gotProject != "entire-e2e" { + t.Fatalf("GOOGLE_CLOUD_PROJECT = %q, want project_id from ADC JSON", gotProject) + } +} + +func TestAntigravityPromptEnvKeepsRealHomeWithoutADCCredentials(t *testing.T) { + t.Parallel() + + base := []string{ + "PATH=/bin", + "HOME=/real-home", + "USE_ADC=1", + } + + got := antigravityPromptEnvFrom(base, "/tmp/repo") + + if gotHome, _ := envValue(got, "HOME"); gotHome != "/real-home" { + t.Fatalf("HOME = %q, want real home when GOOGLE_APPLICATION_CREDENTIALS is absent", gotHome) + } + if gotADC, _ := envValue(got, "USE_ADC"); gotADC != "1" { + t.Fatalf("USE_ADC = %q, want existing value preserved", gotADC) + } +} + +func TestAntigravityStartupClassifierTreatsTrustSelectorAsConfirmation(t *testing.T) { + t.Parallel() + + content := `Accessing workspace: + +/private/tmp/repo + +Do you trust the contents of this project? + +Antigravity may read files and run commands in this folder. + +> Yes, I trust this folder + No, exit` + + if !antigravityNeedsStartupConfirmation(content) { + t.Fatal("trust selector should require confirmation") + } + if antigravityReadyForPrompt(content) { + t.Fatal("trust selector should not be treated as the ready prompt") + } +} + +func TestAntigravityStartupClassifierAcceptsReadyPrompt(t *testing.T) { + t.Parallel() + + content := `Accessing workspace: + +/private/tmp/repo + +>` + + if antigravityNeedsStartupConfirmation(content) { + t.Fatal("ready prompt should not require confirmation") + } + if !antigravityReadyForPrompt(content) { + t.Fatal("ready prompt should be accepted") + } +} + +func TestAntigravityIsTransientErrorRecognizesErrorText(t *testing.T) { + t.Parallel() + + a := &Antigravity{} + err := stringError("antigravity transcript contains ERROR_MESSAGE error_code=429: model API is currently overloaded") + if !a.IsTransientError(Output{}, err) { + t.Fatal("IsTransientError() = false, want true for transient error text") + } +} + +func TestAntigravityQuotaExhaustedIsFatalNotTransient(t *testing.T) { + t.Parallel() + + // agy wraps the hard quota wall in "overloaded" + "429" + "RESOURCE_EXHAUSTED", + // all of which the transient patterns would otherwise match. The quota-reached + // marker must win so the harness fails fast instead of restarting for ~53h. + out := Output{Stderr: "The model API is currently overloaded: RESOURCE_EXHAUSTED (code 429): Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 53h35m43s."} + if (&Antigravity{}).IsTransientError(out, stringError(out.Stderr)) { + t.Fatal("Individual quota reached must be fatal (non-retryable), not a transient restart") + } +} + +func TestAntigravityEntitlementWallsAreFatal(t *testing.T) { + t.Parallel() + + for _, msg := range []string{ + "PERMISSION_DENIED (code 403): Cloud Code Private API ... reason: SERVICE_DISABLED", + "AUTH_PERMISSION_DENIED subject: 110002", + "error getting token source: You are not logged into Antigravity.", + } { + if (&Antigravity{}).IsTransientError(Output{Stderr: msg}, stringError(msg)) { + t.Errorf("config/entitlement wall must be fatal, not transient: %q", msg) + } + } +} + +func TestAntigravityGenuineOverloadStillRetries(t *testing.T) { + t.Parallel() + + // A real transient overload with no quota/entitlement marker must still retry. + out := Output{Stderr: "The model API is currently overloaded and may experience intermittent errors. (503 UNAVAILABLE)"} + if !(&Antigravity{}).IsTransientError(out, stringError(out.Stderr)) { + t.Fatal("genuine transient overload (no quota/auth marker) should still retry") + } +} + +func TestAntigravityMissingCommitIsNotTransient(t *testing.T) { + t.Parallel() + + a := &Antigravity{} + err := stringError("antigravity did not create requested commit; HEAD unchanged") + if a.IsTransientError(Output{}, err) { + t.Fatal("missing commit should fail the prompt, not restart the whole scenario as a transient API error") + } +} + +func TestAntigravityPromptTranscriptTransientMatchesCurrentPrompt(t *testing.T) { + t.Parallel() + + brainDir := t.TempDir() + path := filepath.Join(brainDir, "conv-123", ".system_generated", "logs", "transcript_full.jsonl") + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + data := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"\nmake docs/example.md\n","status":"DONE"}` + "\n" + + `{"step_index":1,"source":"SYSTEM","type":"ERROR_MESSAGE","error":"The model API is currently overloaded and may experience intermittent errors.","error_code":429,"status":"DONE"}` + "\n" + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + + got, ok := antigravityPromptTranscriptTransient(brainDir, "make docs/example.md", time.Now().Add(-time.Minute)) + if !ok { + t.Fatal("antigravityPromptTranscriptTransient() ok = false, want true") + } + if got == "" { + t.Fatal("antigravityPromptTranscriptTransient() message is empty") + } +} + +func TestAntigravityPromptTranscriptTransientIgnoresRecoveredInvalidToolCall(t *testing.T) { + t.Parallel() + + brainDir := t.TempDir() + path := filepath.Join(brainDir, "conv-123", ".system_generated", "logs", "transcript_full.jsonl") + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + data := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"\nmake docs/example.md\n","status":"DONE"}` + "\n" + + `{"step_index":1,"source":"SYSTEM","type":"ERROR_MESSAGE","error":"There was a problem parsing the tool call. Error Message: model output error: invalid tool call error (invalid_args)","status":"DONE"}` + "\n" + + `{"step_index":2,"source":"MODEL","type":"PLANNER_RESPONSE","content":"Done.","status":"DONE"}` + "\n" + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + + if got, ok := antigravityPromptTranscriptTransient(brainDir, "make docs/example.md", time.Now().Add(-time.Minute)); ok { + t.Fatalf("antigravityPromptTranscriptTransient() = %q, true; want no match for recovered tool error", got) + } +} + +func TestAntigravityRawToolCallOutputRecognized(t *testing.T) { + t.Parallel() + + cases := []string{ + `<|start|>assistant<|channel|>commentary to=functions.run_command <|constrain|>json<|message|>{"CommandLine":"mkdir -p docs"}<|call|>`, + `<|channel|>commentary<|message|>{"CommandLine":"git add docs/red.md && git commit -m \"Add red doc\"","toolAction":"Committing file"}`, + } + for _, content := range cases { + if !antigravityRawToolCallOutput(content) { + t.Fatalf("antigravityRawToolCallOutput() = false for %q, want true", content) + } + if !(&Antigravity{}).IsTransientError(Output{Stdout: content}, stringError("antigravity emitted raw tool call output")) { + t.Fatalf("IsTransientError() = false for %q, want raw tool output to be retryable", content) + } + } +} + +func TestAntigravityPromptTranscriptTransientIgnoresOtherPrompt(t *testing.T) { + t.Parallel() + + brainDir := t.TempDir() + path := filepath.Join(brainDir, "conv-123", ".system_generated", "logs", "transcript_full.jsonl") + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + data := `{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","content":"\nchange another file\n","status":"DONE"}` + "\n" + + `{"step_index":1,"source":"SYSTEM","type":"ERROR_MESSAGE","error":"The model API is currently overloaded and may experience intermittent errors.","error_code":429,"status":"DONE"}` + "\n" + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } + + if got, ok := antigravityPromptTranscriptTransient(brainDir, "make docs/example.md", time.Now().Add(-time.Minute)); ok { + t.Fatalf("antigravityPromptTranscriptTransient() = %q, true; want no match", got) + } +} + +func TestAntigravityAuthenticationRequiredRecognized(t *testing.T) { + t.Parallel() + + content := `Authentication required. Please visit the URL to log in: + https://accounts.google.com/o/oauth2/auth?state=abc + +Waiting for authentication (timeout 30s)... +Or, paste the authorization code here and press Enter: +Error: authentication timed out.` + if !antigravityAuthenticationRequired(content) { + t.Fatal("antigravityAuthenticationRequired() = false, want true") + } + if (&Antigravity{}).IsTransientError(Output{Stdout: content}, nil) { + t.Fatal("authentication timeout should not be treated as transient") + } +} + +func TestAntigravityAuthenticationRequiredRecognizesLowercasePrintModeError(t *testing.T) { + t.Parallel() + + content := "Error: authentication required. Run 'agy' to log in." + if !antigravityAuthenticationRequired(content) { + t.Fatal("antigravityAuthenticationRequired() = false, want true for lowercase print-mode auth error") + } +} + +type stringError string + +func (e stringError) Error() string { return string(e) } + +func countEnv(env []string, key string) int { + count := 0 + for _, entry := range env { + if len(entry) > len(key)+1 && entry[:len(key)+1] == key+"=" { + count++ + } + } + return count +} + +func TestAntigravitySessionCLIArgsIncludeWorkspaceAndProject(t *testing.T) { + t.Parallel() + + args := antigravitySessionCLIArgsFromEnv("/repo", "gemini-2.5-pro", []string{ + "GOOGLE_CLOUD_PROJECT=entire-e2e", + }) + + want := []string{ + "agy", + "--model", "gemini-2.5-pro", + "--dangerously-skip-permissions", + "--new-project", + "--add-dir", "/repo", + "--project", "entire-e2e", + } + if !slices.Equal(args, want) { + t.Fatalf("args = %#v, want %#v", args, want) + } +} + +func TestAntigravityInteractiveSessionWrapsSend(t *testing.T) { + t.Parallel() + + rec := &recordingSession{} + session := &antigravityInteractiveSession{Session: rec, dir: "/repo"} + + if err := session.Send("create docs/red.md"); err != nil { + t.Fatalf("Send: %v", err) + } + if len(rec.inputs) != 1 { + t.Fatalf("recorded inputs = %#v, want one", rec.inputs) + } + if !strings.Contains(rec.inputs[0], "Use the workspace at /repo") { + t.Fatalf("wrapped prompt missing workspace: %q", rec.inputs[0]) + } + if !strings.Contains(rec.inputs[0], "Request:\ncreate docs/red.md") { + t.Fatalf("wrapped prompt missing original request: %q", rec.inputs[0]) + } +} + +func TestAntigravityInteractiveSessionRetriesRawToolCallOutput(t *testing.T) { + t.Parallel() + + raw := `<|channel|>commentary<|message|>{"CommandLine":"git add hello.txt && git commit -m Add","toolAction":"Creating commit"}` + rec := &recordingSession{waits: []string{raw, "done\n>"}} + session := &antigravityInteractiveSession{Session: rec, dir: "/repo"} + + if err := session.Send("create hello.txt and commit it"); err != nil { + t.Fatalf("Send: %v", err) + } + content, err := session.WaitFor(">", time.Second) + if err != nil { + t.Fatalf("WaitFor: %v", err) + } + if content != "done\n>" { + t.Fatalf("WaitFor content = %q, want final retry content", content) + } + if len(rec.inputs) != 2 { + t.Fatalf("recorded inputs = %#v, want original send plus retry", rec.inputs) + } + if !strings.Contains(rec.inputs[1], "Request:\ncreate hello.txt and commit it") { + t.Fatalf("retry prompt missing original request: %q", rec.inputs[1]) + } +} + +func TestAntigravityInteractiveSessionRetriesRequestedCommitWhenHeadUnchanged(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + runGitForAntigravityTest(t, dir, "init") + runGitForAntigravityTest(t, dir, "config", "user.name", "E2E Test") + runGitForAntigravityTest(t, dir, "config", "user.email", "e2e@test.local") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("initial\n"), 0o644); err != nil { + t.Fatal(err) + } + runGitForAntigravityTest(t, dir, "add", "README.md") + runGitForAntigravityTest(t, dir, "commit", "-m", "initial commit") + + rec := &recordingSession{waits: []string{"claimed committed\n>", "done\n>"}} + session := &antigravityInteractiveSession{Session: rec, dir: dir} + + if err := session.Send("create hello.txt and commit it"); err != nil { + t.Fatalf("Send: %v", err) + } + content, err := session.WaitFor(">", time.Second) + if err != nil { + t.Fatalf("WaitFor: %v", err) + } + if content != "done\n>" { + t.Fatalf("WaitFor content = %q, want retry content", content) + } + if len(rec.inputs) != 2 { + t.Fatalf("recorded inputs = %#v, want original send plus commit retry", rec.inputs) + } + if !strings.Contains(rec.inputs[1], "HEAD has not changed") { + t.Fatalf("commit retry prompt missing HEAD guidance: %q", rec.inputs[1]) + } + if !strings.Contains(rec.inputs[1], "Do not modify repository file contents") { + t.Fatalf("commit retry prompt should forbid content edits: %q", rec.inputs[1]) + } + if !strings.Contains(rec.inputs[1], "Previous request:\ncreate hello.txt and commit it") { + t.Fatalf("commit retry prompt missing original request: %q", rec.inputs[1]) + } +} + +func runGitForAntigravityTest(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +func TestAntigravityShouldRetryMissingCommit(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + runGitForAntigravityTest(t, dir, "init") + runGitForAntigravityTest(t, dir, "config", "user.name", "E2E Test") + runGitForAntigravityTest(t, dir, "config", "user.email", "e2e@test.local") + if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("initial\n"), 0o644); err != nil { + t.Fatal(err) + } + runGitForAntigravityTest(t, dir, "add", "README.md") + runGitForAntigravityTest(t, dir, "commit", "-m", "initial commit") + head := antigravityGitHead(dir) + + if !antigravityShouldRetryMissingCommit("create docs/red.md and commit it", dir, head) { + t.Fatal("antigravityShouldRetryMissingCommit() = false, want true when commit requested and HEAD unchanged") + } + if antigravityShouldRetryMissingCommit("create docs/red.md but do not commit", dir, head) { + t.Fatal("antigravityShouldRetryMissingCommit() = true, want false when prompt says not to commit") + } + + if err := os.WriteFile(filepath.Join(dir, "next.md"), []byte("next\n"), 0o644); err != nil { + t.Fatal(err) + } + runGitForAntigravityTest(t, dir, "add", "next.md") + runGitForAntigravityTest(t, dir, "commit", "-m", "next commit") + if antigravityShouldRetryMissingCommit("create docs/red.md and commit it", dir, head) { + t.Fatal("antigravityShouldRetryMissingCommit() = true, want false after HEAD changed") + } +} + +type recordingSession struct { + inputs []string + waits []string +} + +func (r *recordingSession) Send(input string) error { + r.inputs = append(r.inputs, input) + return nil +} + +func (r *recordingSession) WaitFor(string, time.Duration) (string, error) { + if len(r.waits) == 0 { + return "", nil + } + content := r.waits[0] + r.waits = r.waits[1:] + return content, nil +} +func (r *recordingSession) Capture() string { return "" } +func (r *recordingSession) Close() error { return nil } + +// TestAntigravityFatalFromLogs pins the headless quota-wall detection: in -p +// mode agy puts "Individual quota reached" ONLY in its CLI log — the +// transcript's ERROR_MESSAGE carries just the generic "overloaded" text and +// stderr is empty, so without the log peek the harness retries into a wall. +func TestAntigravityFatalFromLogs(t *testing.T) { + dir := t.TempDir() + t.Setenv("E2E_ANTIGRAVITY_LOG_DIR", dir) + + logLine := "E0703 15:23:40.1 log.go:398] RESOURCE_EXHAUSTED (code 429): Individual quota reached. Please upgrade your subscription to increase your limits. Resets in 1h46m10s.\n" + if err := os.WriteFile(filepath.Join(dir, "cli-20260703_152340.log"), []byte(logLine), 0o600); err != nil { + t.Fatal(err) + } + + msg, fatal := antigravityFatalFromLogs(time.Now().Add(-time.Minute), t.TempDir()) + if !fatal { + t.Fatal("quota wall in agy CLI log must be detected as fatal") + } + if msg == "" { + t.Fatal("fatal log detection must return an actionable message") + } + + // Logs older than `since` must be ignored (stale walls from prior runs). + _, fatalOld := antigravityFatalFromLogs(time.Now().Add(time.Hour), t.TempDir()) + if fatalOld { + t.Error("log files older than the prompt start must not be considered") + } +} + +// TestAntigravityFatalFromLogs_ADCIsolatedHome pins the ADC-mode path: with +// GOOGLE_APPLICATION_CREDENTIALS set, agy runs with HOME redirected to the +// per-repo test home (antigravityTestHomeDir), so its cli-*.log files land +// there — NOT under the harness process's real HOME. The log peek must look +// in the same isolated home or a CI quota wall is misclassified as transient +// and retried into. +func TestAntigravityFatalFromLogs_ADCIsolatedHome(t *testing.T) { + repoDir := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(repoDir, 0o750); err != nil { + t.Fatal(err) + } + credsPath := filepath.Join(t.TempDir(), "adc.json") + if err := os.WriteFile(credsPath, []byte(`{"project_id":"p"}`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", credsPath) + t.Setenv("E2E_ANTIGRAVITY_LOG_DIR", "") + + logDir := filepath.Join(antigravityTestHomeDir(repoDir), ".gemini", "antigravity-cli", "log") + if err := os.MkdirAll(logDir, 0o750); err != nil { + t.Fatal(err) + } + logLine := "E0703 15:23:40.1 log.go:398] RESOURCE_EXHAUSTED (code 429): Individual quota reached. Resets in 1h46m10s.\n" + if err := os.WriteFile(filepath.Join(logDir, "cli-20260703_152340.log"), []byte(logLine), 0o600); err != nil { + t.Fatal(err) + } + + _, fatal := antigravityFatalFromLogs(time.Now().Add(-time.Minute), repoDir) + if !fatal { + t.Fatal("ADC-mode quota wall in the isolated home's agy CLI log must be detected as fatal") + } +} + +// TestAntigravityFatalErrorMatchesOwnMessages pins idempotent classification: +// once a fatal log finding is folded into an error, IsTransientError must +// still classify the combined text as fatal (non-transient) even though the +// raw marker may only appear in our own generated message. +func TestAntigravityFatalErrorMatchesOwnMessages(t *testing.T) { + t.Parallel() + for _, msg := range []string{ + "agy individual quota exhausted (consumer tier resets on a multi-day window) — use an entitled account/ADC or wait for the reset shown in the error", + "agy backend not provisioned for this project/identity (cloudcode-pa is gated) — needs a Gemini Code Assist subscription + seat, not just an enabled API", + "agy is not logged in — authenticate with `agy` interactively, set GEMINI_API_KEY, or provide ADC credentials", + "agy Gemini API key mode misconfigured: modelProvider is gemini but GEMINI_API_KEY is unset in the spawned env", + "agy rejected the Gemini API key (API_KEY_INVALID) — check the GEMINI_API_KEY secret", + } { + if (&Antigravity{}).IsTransientError(Output{}, stringError(msg+": some wrapped transient 429 overloaded text")) { + t.Errorf("classifier must treat its own fatal message as fatal: %q", msg) + } + } +} + +func TestAntigravityBootstrapAcceptsGeminiAPIKeyInCI(t *testing.T) { + t.Setenv("CI", "true") + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + t.Setenv("GEMINI_API_KEY", "test-key") + + if err := (&Antigravity{}).Bootstrap(); err != nil { + t.Fatalf("Bootstrap() error = %v, want nil with GEMINI_API_KEY", err) + } +} + +func TestAntigravityAuthModePrecedence(t *testing.T) { + t.Parallel() + cases := []struct { + name string + env []string + want antigravityAuthMode + }{ + {"oauth when nothing set", []string{"HOME=/h"}, antigravityAuthOAuth}, + {"api key", []string{"HOME=/h", "GEMINI_API_KEY=k"}, antigravityAuthAPIKey}, + {"adc", []string{"HOME=/h", "GOOGLE_APPLICATION_CREDENTIALS=/c.json"}, antigravityAuthADC}, + {"adc wins over api key", []string{"HOME=/h", "GEMINI_API_KEY=k", "GOOGLE_APPLICATION_CREDENTIALS=/c.json"}, antigravityAuthADC}, + {"empty api key is unset", []string{"HOME=/h", "GEMINI_API_KEY="}, antigravityAuthOAuth}, + } + for _, tc := range cases { + if got := antigravityAuthModeFrom(tc.env); got != tc.want { + t.Errorf("%s: antigravityAuthModeFrom() = %v, want %v", tc.name, got, tc.want) + } + } +} + +func TestAntigravityPromptEnvUsesGeminiAPIKeyWithIsolatedHome(t *testing.T) { + t.Parallel() + + base := []string{ + "PATH=/bin", + "ENTIRE_TEST_TTY=1", + "HOME=/real-home", + "USE_ADC=1", + "GOOGLE_CLOUD_PROJECT=stale", + "GEMINI_API_KEY=test-key", + "GOOGLE_API_KEY=other-key", + "GOOGLE_GEMINI_BASE_URL=https://proxy.example", + } + + got := antigravityPromptEnvFrom(base, "/tmp/repo") + + if gotHome, _ := envValue(got, "HOME"); gotHome != "/tmp/repo-antigravity-home" { + t.Fatalf("HOME = %q, want isolated test home in API-key mode", gotHome) + } + if gotKey, _ := envValue(got, "GEMINI_API_KEY"); gotKey != "test-key" { + t.Fatalf("GEMINI_API_KEY = %q, want passed through", gotKey) + } + if _, ok := envValue(got, "GOOGLE_API_KEY"); ok { + t.Fatalf("GOOGLE_API_KEY must be scrubbed (agy prefers it over GEMINI_API_KEY when both are set), env=%#v", got) + } + if _, ok := envValue(got, "USE_ADC"); ok { + t.Fatalf("USE_ADC must not leak into API-key mode, env=%#v", got) + } + if _, ok := envValue(got, "GOOGLE_CLOUD_PROJECT"); ok { + t.Fatalf("GOOGLE_CLOUD_PROJECT must not leak into API-key mode, env=%#v", got) + } + if gotURL, _ := envValue(got, "GOOGLE_GEMINI_BASE_URL"); gotURL != "https://proxy.example" { + t.Fatalf("GOOGLE_GEMINI_BASE_URL = %q, want passed through", gotURL) + } + if countEnv(got, "HOME") != 1 { + t.Fatalf("HOME should be upserted once, env=%#v", got) + } +} + +func TestAntigravityPromptEnvADCScrubsAPIKeys(t *testing.T) { + t.Parallel() + + base := []string{ + "HOME=/real-home", + "GOOGLE_APPLICATION_CREDENTIALS=/creds.json", + "GEMINI_API_KEY=test-key", + "GOOGLE_API_KEY=other-key", + } + + got := antigravityPromptEnvFrom(base, "/tmp/repo") + + if _, ok := envValue(got, "GEMINI_API_KEY"); ok { + t.Fatalf("GEMINI_API_KEY must be scrubbed in ADC mode, env=%#v", got) + } + if _, ok := envValue(got, "GOOGLE_API_KEY"); ok { + t.Fatalf("GOOGLE_API_KEY must be scrubbed in ADC mode, env=%#v", got) + } + if gotADC, _ := envValue(got, "USE_ADC"); gotADC != "1" { + t.Fatalf("USE_ADC = %q, want 1", gotADC) + } +} + +func TestAntigravitySessionEnvUsesGeminiAPIKeyWithIsolatedHome(t *testing.T) { + t.Parallel() + + base := []string{ + "HOME=/real-home", + "TERM=xterm-256color", + "GEMINI_API_KEY=test-key", + "GOOGLE_API_KEY=other-key", + } + + envArgs, unsetEnv := antigravitySessionEnv(base, "/tmp/repo") + + if gotHome, _ := envValue(envArgs, "HOME"); gotHome != "/tmp/repo-antigravity-home" { + t.Fatalf("HOME = %q, want isolated test home in API-key mode", gotHome) + } + for _, name := range []string{"HOME", "GOOGLE_API_KEY", "USE_ADC"} { + if !slices.Contains(unsetEnv, name) { + t.Fatalf("unsetEnv = %#v, want %s cleared before the tmux `env` override", unsetEnv, name) + } + } + if slices.Contains(unsetEnv, "GEMINI_API_KEY") { + t.Fatalf("GEMINI_API_KEY must be inherited by the interactive session, unsetEnv=%#v", unsetEnv) + } +} + +func TestAntigravityEnsureIsolatedHomeWritesModelProviderAndTrust(t *testing.T) { + t.Parallel() + + repoDir := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(repoDir, 0o750); err != nil { + t.Fatal(err) + } + settingsPath := filepath.Join(antigravityTestHomeDir(repoDir), ".gemini", "antigravity-cli", "settings.json") + + if err := antigravityEnsureIsolatedHome(repoDir, true); err != nil { + t.Fatalf("antigravityEnsureIsolatedHome() error = %v", err) + } + data, err := os.ReadFile(settingsPath) + if err != nil { + t.Fatalf("settings.json not written: %v", err) + } + var settings map[string]any + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatalf("settings.json is not JSON: %v\n%s", err, data) + } + if settings["modelProvider"] != "gemini" { + t.Fatalf("modelProvider = %v, want gemini", settings["modelProvider"]) + } + trusted, _ := settings["trustedWorkspaces"].([]any) + if !slices.Contains(trusted, any(repoDir)) { + t.Fatalf("trustedWorkspaces = %v, want the test repo pre-trusted so agy loads .agents/hooks.json", trusted) + } + + // Idempotent and preserving: a pre-existing key survives a second call. + if err := os.WriteFile(settingsPath, []byte(`{"enableTelemetry":false,"modelProvider":"gemini"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := antigravityEnsureIsolatedHome(repoDir, true); err != nil { + t.Fatalf("second antigravityEnsureIsolatedHome() error = %v", err) + } + data, _ = os.ReadFile(settingsPath) + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatal(err) + } + if settings["enableTelemetry"] != false || settings["modelProvider"] != "gemini" { + t.Fatalf("settings after second call = %s, want enableTelemetry preserved and modelProvider gemini", data) + } +} + +func TestAntigravityHomeDirUsesIsolatedHomeInAPIKeyMode(t *testing.T) { + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "") + t.Setenv("GEMINI_API_KEY", "test-key") + + if got := antigravityHomeDir("/tmp/repo"); got != "/tmp/repo-antigravity-home" { + t.Fatalf("antigravityHomeDir() = %q, want isolated home so brain/log peeks read agy's real state", got) + } +} + +func TestAntigravityFatalErrorDetectsAPIKeyWalls(t *testing.T) { + t.Parallel() + cases := map[string]string{ + `modelProvider is set to "gemini" in settings.json, but the GEMINI_API_KEY environment variable is not set. Set GEMINI_API_KEY to your Gemini API key, or remove "modelProvider" from settings.json to use the default backend.`: "GEMINI_API_KEY is unset", + `Error: API key not valid. Please pass a valid API key. [reason: API_KEY_INVALID]`: "API_KEY_INVALID", + } + for content, want := range cases { + msg, fatal := antigravityFatalError(content) + if !fatal { + t.Errorf("antigravityFatalError(%q) fatal = false, want true", content) + continue + } + if !strings.Contains(msg, want) { + t.Errorf("antigravityFatalError(%q) = %q, want message containing %q", content, msg, want) + } + } +} + +func TestAntigravityEnsureIsolatedHomeADCTrustsWithoutProvider(t *testing.T) { + t.Parallel() + + repoDir := filepath.Join(t.TempDir(), "repo") + if err := os.MkdirAll(repoDir, 0o750); err != nil { + t.Fatal(err) + } + if err := antigravityEnsureIsolatedHome(repoDir, false); err != nil { + t.Fatalf("antigravityEnsureIsolatedHome() error = %v", err) + } + data, err := os.ReadFile(filepath.Join(antigravityTestHomeDir(repoDir), ".gemini", "antigravity-cli", "settings.json")) + if err != nil { + t.Fatal(err) + } + var settings map[string]any + if err := json.Unmarshal(data, &settings); err != nil { + t.Fatal(err) + } + if _, ok := settings["modelProvider"]; ok { + t.Fatalf("ADC mode must not set modelProvider (agy would then demand GEMINI_API_KEY): %s", data) + } + trusted, _ := settings["trustedWorkspaces"].([]any) + if !slices.Contains(trusted, any(repoDir)) { + t.Fatalf("trustedWorkspaces = %v, want the test repo", trusted) + } +} + +func TestAntigravityPrepareHomeIsNoOpForOAuth(t *testing.T) { + t.Parallel() + repoDir := filepath.Join(t.TempDir(), "repo") + if err := antigravityPrepareHome([]string{"HOME=/real-home"}, repoDir); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(antigravityTestHomeDir(repoDir)); !os.IsNotExist(err) { + t.Fatalf("OAuth mode must not create an isolated home, stat err = %v", err) + } +} + +func TestAntigravityPrepareRepoAddsProbeHookWhenEnabled(t *testing.T) { + t.Parallel() + repoDir := t.TempDir() + hooksPath := filepath.Join(repoDir, ".agents", "hooks.json") + if err := os.MkdirAll(filepath.Dir(hooksPath), 0o750); err != nil { + t.Fatal(err) + } + original := `{"entire":{"PreInvocation":[{"type":"command","command":"entire hooks antigravity pre-invocation"}]}}` + if err := os.WriteFile(hooksPath, []byte(original), 0o600); err != nil { + t.Fatal(err) + } + + // Disabled: file untouched. + if err := antigravityPrepareRepo([]string{"E2E_ANTIGRAVITY_HOOK_PROBE=0", "CI=true"}, repoDir); err != nil { + t.Fatal(err) + } + if data, _ := os.ReadFile(hooksPath); string(data) != original { + t.Fatalf("probe disabled must leave hooks.json untouched, got %s", data) + } + + // Enabled by CI default. + if err := antigravityPrepareRepo([]string{"CI=true"}, repoDir); err != nil { + t.Fatal(err) + } + data, _ := os.ReadFile(hooksPath) + var hooks map[string]json.RawMessage + if err := json.Unmarshal(data, &hooks); err != nil { + t.Fatalf("hooks.json not JSON after probe: %v\n%s", err, data) + } + if _, ok := hooks["entire"]; !ok { + t.Fatalf("entire entry must be preserved, got %s", data) + } + probe, ok := hooks["entire-e2e-probe"] + if !ok { + t.Fatalf("probe entry missing, got %s", data) + } + for _, want := range []string{"PreInvocation", "PreToolUse", "Stop", "agy-hook-probe.log", "cwd="} { + if !strings.Contains(string(probe), want) { + t.Errorf("probe entry missing %q: %s", want, probe) + } + } + if _, err := os.Stat(filepath.Join(repoDir, ".entire", "logs")); err != nil { + t.Fatalf("probe log dir should exist so the hook can append: %v", err) + } +} + +func TestAntigravityExtraArtifactsOnlyForIsolatedHome(t *testing.T) { + t.Parallel() + if got := antigravityExtraArtifacts([]string{"HOME=/real"}, "/tmp/repo"); got != nil { + t.Fatalf("OAuth mode must not expose the developer HOME as artifacts, got %v", got) + } + got := antigravityExtraArtifacts([]string{"HOME=/real", "GEMINI_API_KEY=k"}, "/tmp/repo") + if got["agy-home-logs"] != "/tmp/repo-antigravity-home/.gemini/antigravity-cli/log" { + t.Fatalf("agy-home-logs = %q", got["agy-home-logs"]) + } + if got["agy-home-settings.json"] != "/tmp/repo-antigravity-home/.gemini/antigravity-cli/settings.json" { + t.Fatalf("agy-home-settings.json = %q", got["agy-home-settings.json"]) + } +} + +// A fresh isolated HOME must arrive already onboarded, or interactive agy sits +// on its color-scheme and Terms screens and every tmux-driven test times out +// waiting for files (the CI leg on 165b0ada53: 5 TestInteractive* failures). +func TestAntigravityEnsureIsolatedHomeSeedsOnboardingComplete(t *testing.T) { + repoDir := t.TempDir() + if err := antigravityEnsureIsolatedHome(repoDir, true); err != nil { + t.Fatalf("antigravityEnsureIsolatedHome() error = %v", err) + } + path := filepath.Join(antigravityTestHomeDir(repoDir), ".gemini", "antigravity-cli", "cache", "onboarding.json") + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("onboarding cache not seeded: %v", err) + } + var got map[string]bool + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("onboarding cache is not JSON: %v\n%s", err, data) + } + if !got["onboardingComplete"] || !got["consumerOnboardingComplete"] { + t.Fatalf("onboarding cache must mark onboarding complete, got %s", data) + } + // A user's own file (here: a different value) is left alone. + if err := os.WriteFile(path, []byte(`{"onboardingComplete":false}`), 0o600); err != nil { + t.Fatal(err) + } + if err := antigravityEnsureIsolatedHome(repoDir, true); err != nil { + t.Fatal(err) + } + if again, _ := os.ReadFile(path); string(again) != `{"onboardingComplete":false}` { + t.Fatalf("existing onboarding cache was overwritten: %s", again) + } +} + +// The two first-run screens must never be mistaken for the prompt: both carry +// a `>` (the chooser's selection marker, the consent's checkbox cursor). +func TestAntigravityStartupScreensAreNotThePrompt(t *testing.T) { + t.Parallel() + chooser := "Welcome to Antigravity CLI!\n\nChoose your color scheme:\n\n > terminal\n light\n" + terms := "Terms of Service & Data Use\n\n > [x] Yes, I agree to help improve Antigravity CLI\n [Previous] [Done]\n ↑/↓ Navigate · enter Toggle\n" + prompt := "────────\n>\n────────\n? for shortcuts Gemini 3.8 Flash · low\n" + + if antigravityReadyForPrompt(chooser) || !antigravityNeedsStartupConfirmation(chooser) { + t.Error("color-scheme chooser must read as a confirmation screen, not the prompt") + } + if antigravityReadyForPrompt(terms) || !antigravityOnTermsScreen(terms) { + t.Error("Terms screen must read as the Terms screen, not the prompt") + } + if antigravityNeedsStartupConfirmation(terms) { + t.Error("Terms screen must not be answered with a bare Enter (it only toggles the checkbox)") + } + if !antigravityReadyForPrompt(prompt) { + t.Error("the real prompt must read as ready") + } +} diff --git a/e2e/testutil/artifacts.go b/e2e/testutil/artifacts.go index 63f5e6dad2..98b30e4e62 100644 --- a/e2e/testutil/artifacts.go +++ b/e2e/testutil/artifacts.go @@ -10,6 +10,8 @@ import ( "strings" "testing" "time" + + "github.com/entireio/cli/e2e/agents" ) // ArtifactRoot is the absolute path to the artifact output directory. @@ -83,6 +85,7 @@ func CaptureArtifacts(t *testing.T, s *RepoState) { captureCheckpointMetadata(t, s, dir) captureEntireLogs(t, s.Dir, dir) + captureAgentArtifacts(t, s, dir) if os.Getenv("E2E_KEEP_REPOS") != "" { if err := linkRepo(s.Dir, dir); err != nil { @@ -149,6 +152,44 @@ func captureEntireLogs(t *testing.T, repoDir, outDir string) { } } +// captureAgentArtifacts copies whatever the agent declares via +// agents.ArtifactCollector (e.g. the agent's own log directory in an isolated +// HOME) into the artifact dir, so a CI failure inside the agent process is +// diagnosable from artifacts alone. +func captureAgentArtifacts(t *testing.T, s *RepoState, outDir string) { + t.Helper() + collector, ok := s.Agent.(agents.ArtifactCollector) + if !ok { + return + } + for name, src := range collector.ExtraArtifacts(s.Dir) { + info, err := os.Stat(src) + if err != nil { + continue + } + dst := filepath.Join(outDir, name) + if !info.IsDir() { + if data, err := os.ReadFile(src); err == nil { + writeArtifact(t, outDir, name, string(data)) + } + continue + } + _ = os.MkdirAll(dst, 0o755) + entries, err := os.ReadDir(src) + if err != nil { + continue + } + for _, e := range entries { + if e.IsDir() { + continue + } + if data, err := os.ReadFile(filepath.Join(src, e.Name())); err == nil { + writeArtifact(t, dst, e.Name(), string(data)) + } + } + } +} + func writeArtifact(t *testing.T, dir, name, content string) { t.Helper() path := filepath.Join(dir, name) diff --git a/e2e/testutil/repo.go b/e2e/testutil/repo.go index c7f7360912..e917eee2fb 100644 --- a/e2e/testutil/repo.go +++ b/e2e/testutil/repo.go @@ -138,6 +138,21 @@ func SetupRepo(t *testing.T, agent agents.Agent) *RepoState { } entire.Enable(t, dir, agent.EntireAgent()) + if preparer, ok := agent.(agents.RepoPreparer); ok { + if err := preparer.PrepareRepo(dir); err != nil { + t.Fatalf("prepare repo for %s: %v", agent.Name(), err) + } + } + // Registered after the repo's own RemoveAll and before artifact capture + // (t.Cleanup runs last-in first-out), so agent state beside the repo is + // still there when artifacts are collected and gone when the test ends. + if cleaner, ok := agent.(agents.RepoCleaner); ok && !keepRepos { + t.Cleanup(func() { + if err := cleaner.CleanupRepo(dir); err != nil { + t.Logf("cleanup agent state for %s: %v", agent.Name(), err) + } + }) + } if agent.Name() == "factoryai-droid" { if err := configureDroidRepoSettings(dir); err != nil { t.Fatalf("configure droid repo settings: %v", err) @@ -471,7 +486,7 @@ func (s *RepoState) RunPrompt(t *testing.T, ctx context.Context, prompt string, s.logPromptResult(out) if err != nil && s.Agent.IsTransientError(out, err) { - errMsg := fmt.Sprintf("transient API error (stderr: %s)", strings.TrimSpace(out.Stderr)) + errMsg := fmt.Sprintf("transient API error: %v (stderr: %s)", err, strings.TrimSpace(out.Stderr)) t.Logf("%s — restarting scenario", errMsg) fmt.Fprintf(s.ConsoleLog, "> [transient] %s — restarting scenario\n", errMsg) panic(errScenarioRestart{msg: errMsg}) diff --git a/e2e/testutil/session_paths.go b/e2e/testutil/session_paths.go index 7f06a19d9d..09a3b85fdd 100644 --- a/e2e/testutil/session_paths.go +++ b/e2e/testutil/session_paths.go @@ -8,6 +8,7 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent/external" "github.com/entireio/cli/cmd/entire/cli/agent/types" + _ "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/e2e/testutil/session_paths_test.go b/e2e/testutil/session_paths_test.go new file mode 100644 index 0000000000..3246826ec0 --- /dev/null +++ b/e2e/testutil/session_paths_test.go @@ -0,0 +1,26 @@ +package testutil + +import ( + "path/filepath" + "testing" + + cliagent "github.com/entireio/cli/cmd/entire/cli/agent" +) + +func TestRestoredSessionTranscriptPathSupportsAntigravity(t *testing.T) { + brainDir := filepath.Join(t.TempDir(), "brain") + t.Setenv("ENTIRE_TEST_ANTIGRAVITY_BRAIN_DIR", brainDir) + + got, ok := RestoredSessionTranscriptPath(t, "/repo", SessionMetadata{ + Agent: string(cliagent.AgentTypeAntigravity), + SessionID: "conv-123", + }) + if !ok { + t.Fatal("RestoredSessionTranscriptPath() ok = false, want true") + } + + want := filepath.Join(brainDir, "conv-123", ".system_generated", "logs", "transcript_full.jsonl") + if got != want { + t.Fatalf("RestoredSessionTranscriptPath() = %q, want %q", got, want) + } +} diff --git a/mise-tasks/test/e2e/_default b/mise-tasks/test/e2e/_default index c457096583..dfa641604c 100755 --- a/mise-tasks/test/e2e/_default +++ b/mise-tasks/test/e2e/_default @@ -1,7 +1,7 @@ #!/bin/sh #MISE description="Run E2E tests: mise run test:e2e --agent claude-code [filter]" #MISE quiet=true -#USAGE flag "--agent " help="Agent (claude-code, opencode, cursor-cli, copilot-cli)" default="" env="E2E_AGENT" +#USAGE flag "--agent " help="Agent (claude-code, antigravity, opencode, cursor-cli, copilot-cli)" default="" env="E2E_AGENT" #USAGE arg "[filter]" help="Test name filter (regex)" default="" set -eu @@ -26,6 +26,10 @@ mkdir -p "$E2E_ARTIFACT_DIR" echo "artifacts: $E2E_ARTIFACT_DIR" filter="${usage_filter:-}" +go_timeout="${E2E_GO_TEST_TIMEOUT:-30m}" +if [ "$E2E_AGENT" = "antigravity" ] && [ -z "$filter" ] && [ -z "${E2E_GO_TEST_TIMEOUT:-}" ]; then + go_timeout="90m" +fi # Use an explicit package path without the "..." wildcard. The wildcard # triggers a Go toolchain panic on Windows ARM64 in modload/search.go @@ -41,7 +45,7 @@ filter="${usage_filter:-}" # tasks — there a failure is always a real bug and must not be masked. gotestsum --format dots --rerun-fails=1 --packages=./e2e/tests \ --jsonfile "$E2E_ARTIFACT_DIR/test-events.json" -- \ - -tags=e2e -count=1 -timeout=30m \ + -tags=e2e -count=1 -timeout="$go_timeout" \ ${filter:+-run "$filter"} || rc=$? go run ./e2e/cmd/testreport -color -o "$E2E_ARTIFACT_DIR/report.txt" "$E2E_ARTIFACT_DIR/test-events.json"