Files
omnigent-ai--omnigent/tests/test_antigravity_native_bridge.py
T
Khoi Nguyen 0da23edd00 fix(antigravity): comprehensive fix for user skills, bypass permissions, subagent execution (#3890)
* fix(antigravity): make the agy harness usable from the web UI

Running agy through Omnigent lost most of what agy was doing. This
brings the web UI to parity with what the terminal already showed.

Every fix below was found by reading live agy RPC traffic and verified
against real sessions; the recorded frames are checked in as fixtures.

**Plugin skills were missing.** An omnigent-spawned agy gets an isolated
`--gemini_dir`, and nothing seeded the user's plugins into it, so
`agy plugin list` was empty under Omnigent while identical outside it.
The bridge now symlinks `config/plugins` and copies `import_manifest.json`.

**The slash menu offered Claude's skills.** The skill-source registry
had no antigravity family, so agy sessions fell through to the
claude-native provider. agy now has its own five sources, with plugin
skills namespaced `<plugin>:<skill>` and enabled only when `plugin.json`
is present.

**`--dangerously-skip-permissions` was unreachable.** claude-code exposes
its bypass in the new-chat dialog; agy had no equivalent, so the flag
could only be set by hand-editing launch args. Added as a capability with
the same danger banner.

**Sub-agents forked duplicate top-level sessions.** agy spawns each
sub-agent as its own cascade, and a working sub-agent is always more
recently active than the parent idling behind it — so the rotation
detector read every spawn as a `/clear` and dragged the pane onto the
child. Children are now identified by `trajectoryMetadata` and skipped.

**Cold start could bind a stranger's agy.** With several agy processes
alive, a session could attach to another one's RPC port and mirror its
conversation. Ownership is now confirmed after `StartCascade`. Port
attribution also moved from shelling out to `lsof` — an undeclared
dependency absent from many images, and unavailable on Windows — to
psutil, which is already a dependency, with a `/proc/net/tcp` fallback.

**Replies duplicated and truncated.** The streaming reader stamped a
constant `"index": 0` on every text delta, and the server discards any
chunk whose index does not advance — so the first chunk rendered, the
rest were dropped, and the unretired buffer replayed to later
subscribers. Deltas now carry a real index, and the live block is closed
on both the stream and poll paths.

**No tool call was ever mirrored.** agy serves each step at two
fidelities: the snapshot RPC carries `metadata.toolCall` and
`plannerResponse.toolCalls`, while the live stream strips both (each
embeds a `thinkingSignature` blob). The mapper was built against the
snapshot, so streamed turns recorded 611 tool outputs against 0
invocations — naked result blobs, most keyed to invented `_orphan_N`
ids, with `view_file` and `invoke_subagent` results dropped entirely.
Both items now derive from the result step, which both shapes deliver in
full, keyed on its own `(trajectory, step)` identity so a stream->poll
fallback cannot re-key a pair.

**Sub-agent work was invisible.** agy names each sub-agent's cascade,
role and type on the parent's `INVOKE_SUBAGENT` step, but nothing
mirrored them, so a four-reviewer dispatch showed one opaque tool call
and an empty Agents rail. Each child now gets a child session and a
mirror loop. `invoke_subagent` is fire-and-forget — its step reaches DONE
while the child runs on for minutes — so each mirror ends on its own
child's turn closing, with agy's run status as the backstop for a turn
that never closes.

Test plan:
- 730 passed, 1 skipped across the antigravity selection; pre-commit clean.
- 6 stream-projection fixtures are verbatim live frames — the shape that
  had no coverage, which is why the tool-call bug shipped.
- Every fix verified end-to-end against a live agy: `agy plugin list`
  A/B, the `/skills` panel, live SSE captures for the delta index, and a
  replay of the real conversations for tool calls (18 tool steps -> 18
  complete pairs, both RPC shapes agreeing) and sub-agents (children that
  had recorded 1 item each now mirror their full transcripts and close).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>

* test(e2e-ui): cover agy's permission toggle with Playwright

The E2E UI Required gate rejected the PR: the new-chat dialog gained
agy's permission control with only Vitest coverage under web/, and no
Playwright test exercising it. The gate is right — this is the toggle
that arms `--dangerously-skip-permissions`, and the repo requires a UI
test for user-facing UI changes.

Two tests, driving a real browser against the stubbed landing picker:

* arming the bypass raises the red danger banner and rides along to
  `POST /v1/sessions` as
  `terminal_launch_args: ["--dangerously-skip-permissions"]`;
* leaving it alone sends NO launch args, so a session cannot silently
  inherit the bypass the user never chose.

The banner assertion is the point of the first test as much as the flag
is. agy fires no pre-tool hook, so once the bypass is armed Omnigent
cannot re-gate individual tools — the warning is the only thing between
the user and an agent that edits any file and runs any command without
asking. The test also asserts the banner is ABSENT before opting in, so
it cannot decay into permanent furniture that users learn to ignore.

Both reuse the module's existing `_antigravity_native_agents_body`
stub rather than adding a second one.

Test plan:
- Both pass in a real chromium run (2 passed), and the whole
  `test_start_session.py` file passes (22 passed).
- Each assertion verified to bite: emptying the flag's `args` fails the
  launch-args assertion, and suppressing the banner fails the visibility
  assertion.
- pre-commit clean.

Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(antigravity): address PR review on the agy harness

Follow-up to the agy harness work, resolving reviewer feedback on #3890.

- Bump the psutil floor to >=6: the connect-RPC port discovery calls
  Process.net_connections(), which 5.9 spells connections(). The
  AttributeError there is neither psutil.Error nor OSError, so it escaped
  the fallback instead of degrading to lsof.
- Seed the Global and Shared agy skill trees into the isolated Gemini dir
  alongside plugins, so the /skills menu cannot offer a skill agy would
  fail to expand. The other two sources need nothing: agy recreates its
  builtins under any --gemini_dir, and the workspace tree is not under it.
- Take a sub-agent's own nested mirrors down with it: a child's steps run
  the same path as the parent's, so a nested INVOKE_SUBAGENT registered a
  grandchild the reader's teardown drain never walked.
- Back the sub-agent quiescence window off after each veto instead of
  resetting it flat. agy answering "still running" can only veto the
  close, so a flat window re-asked every minute for the whole session.
- Fix two comments still attributing child exclusion to trajectoryType,
  which a subagent reports byte-identically to a root.
- Use a per-step chunk counter for planner delta indices. The forwarded
  byte offset moves backwards on a shorter post-moderation rewrite, and
  the server drops any chunk that does not outrank the last accepted one,
  so the closing final chunk was discarded and the block never closed.
- Prefer an exact match before the prefix scan in _arguments_from_body so
  a suffixed sibling key cannot shadow the argument that was asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>

* fix(sessions): import the agy sub-agent symbols explicitly

The sub-agent start path resolved its symbols through the sessions
wildcard imports, which main has since replaced with explicit blocks. The
references now fall through to NameError on the first
external_antigravity_subagent_start event.

Import each symbol from its owning module, matching how the codex
equivalents are already listed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>

* fix(antigravity): catch AttributeError from psutil's pre-6.0 connections API

The connect-RPC port discovery calls Process.net_connections(), the psutil
6.0 rename of connections(). The dependency floor still admits 5.9, where
the attribute is simply absent — and an AttributeError is neither a
psutil.Error nor an OSError, so it escaped the fallback instead of
degrading to lsof, which the docstring already promised.

Widen the except rather than raising the floor. Both say "psutil discovery
does not work on 5.9", but this one says it in code and leaves pyproject
and uv.lock byte-identical to main: the lockfile edit was the sole trigger
for the OSV advisory scan, which then blocked the whole pipeline on
cryptography advisories inherited from main's baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>

* fix(antigravity): keep streamed tool turns running and label agy sub-agents

Addresses the second review pass on #3890.

- Key the turn-close edge on assistant text rather than the absence of
  plannerResponse.toolCalls. The stream strips that field, so a streamed
  tool dispatch (DONE, no toolCalls, no text) was read as a degenerate
  close and fired IDLE the moment agy called a tool — the spinner cleared
  mid-turn and RUNNING could not re-open. Text is the only discriminator
  that holds in both RPC shapes; a genuinely degenerate turn is now
  reconciled by the existing idle backstop instead.
- Register antigravity's sub-agent wrapper so the Agents rail renders the
  child's role instead of the cascade UUID. The label also feeds the chat
  header and composer, which were falling back to the internal agent name.
- Advance the planner delta prefix tracker only when a delta is actually
  emitted. It records what the server received, so re-anchoring it on a
  frame that emitted nothing cut the next delta from the wrong offset and
  duplicated text in the live block. Left the reasoning sibling's
  unconditional re-anchor alone — it has no committed close to flush the
  remainder — and corrected its comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>

---------

Signed-off-by: Khoi Nguyen <khoifish@goodstorytime.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 19:40:01 +08:00

2071 lines
85 KiB
Python

"""Tests for native Antigravity bridge state helpers."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from types import SimpleNamespace
import pytest
import omnigent.antigravity_native_bridge as _mod
from omnigent.antigravity_native_bridge import (
ANTIGRAVITY_NATIVE_BRIDGE_DIR_ENV_VAR,
ANTIGRAVITY_NATIVE_REQUEST_SESSION_ID_ENV_VAR,
AntigravityNativeBridgeState,
agy_gemini_dir,
agy_home_dir,
build_antigravity_native_spawn_env,
build_mcp_config,
clear_bridge_state,
ensure_agy_feedback_survey_disabled,
ensure_agy_onboarding_complete,
inject_user_message_via_tui,
prepare_bridge_dir,
read_bridge_state,
read_tmux_info,
seed_isolated_agy_home,
send_interaction_keys_via_tui,
update_conversation_id,
write_bridge_state,
write_mcp_bridge_config,
write_mcp_config,
write_tmux_target,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _seed_active_turn(bridge_dir: Path, active_turn_id: str | None) -> None:
"""
Write bridge state with a given active turn id.
:param bridge_dir: Native Antigravity bridge directory.
:param active_turn_id: Active turn id to seed, e.g. ``"turn_1"``,
or ``None`` for no running turn.
:returns: None.
"""
write_bridge_state(
bridge_dir,
AntigravityNativeBridgeState(
session_id="conv_test",
conversation_id="conv_agy_test",
active_turn_id=active_turn_id,
),
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def bridge_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""
Create an isolated bridge directory rooted under ``tmp_path``.
:param tmp_path: pytest temp directory.
:param monkeypatch: pytest monkeypatch fixture.
:returns: Prepared bridge directory.
"""
monkeypatch.setattr(_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
return prepare_bridge_dir("bridge_test")
# ---------------------------------------------------------------------------
# State roundtrip
# ---------------------------------------------------------------------------
def test_state_roundtrip_full(bridge_dir: Path) -> None:
"""
Written state reads back equal, including active_turn_id when set.
Guards the full field set: a dropped or renamed field would cause the
harness or forwarder to read stale/None values and lose track of the
conversation.
"""
state = AntigravityNativeBridgeState(
session_id="conv_abc123",
conversation_id="agy_conv_xyz",
active_turn_id="turn_abc123",
)
write_bridge_state(bridge_dir, state)
read_back = read_bridge_state(bridge_dir)
assert read_back == state
def test_state_roundtrip_active_turn_none(bridge_dir: Path) -> None:
"""
Written state with active_turn_id=None reads back with active_turn_id=None.
Guards the idle state: the default field value must survive the JSON
roundtrip so the forwarder does not wrongly report a running turn.
"""
state = AntigravityNativeBridgeState(
session_id="conv_abc123",
conversation_id="agy_conv_xyz",
active_turn_id=None,
)
write_bridge_state(bridge_dir, state)
read_back = read_bridge_state(bridge_dir)
assert read_back == state
assert read_back is not None
assert read_back.active_turn_id is None
def test_state_roundtrip_conversation_id(bridge_dir: Path) -> None:
"""
The conversation_id field roundtrips verbatim.
The forwarder discovers agy's real UUID and writes it here; a dropped or
mangled value would break resume (which reads this id back to pass to
``agy --conversation``).
"""
state = AntigravityNativeBridgeState(
session_id="conv_abc",
conversation_id="68caaeac-2eaf-4e2c-9b95-721b022f4903",
)
write_bridge_state(bridge_dir, state)
read_back = read_bridge_state(bridge_dir)
assert read_back is not None
assert read_back.conversation_id == "68caaeac-2eaf-4e2c-9b95-721b022f4903"
# ---------------------------------------------------------------------------
# legacy agy_pid key (removed field)
# ---------------------------------------------------------------------------
def test_read_bridge_state_ignores_legacy_agy_pid_key(bridge_dir: Path) -> None:
"""
A legacy state.json carrying the removed ``agy_pid`` key reads back fine.
The ``agy_pid`` field was removed (agy is launched under
``tmux_start_on_attach`` so no pid exists at launch; the executor always
discovers the connect-RPC port at injection time). A state file written by
an older build must not crash the reader; the obsolete key is ignored and
the remaining fields parse normally.
:param bridge_dir: Isolated bridge directory fixture.
:returns: None.
"""
(bridge_dir / "state.json").write_text(
json.dumps(
{
"session_id": "conv_abc",
"conversation_id": "agy_conv",
"active_turn_id": "turn_1",
"agy_pid": 72753,
}
)
+ "\n",
encoding="utf-8",
)
state = read_bridge_state(bridge_dir)
assert state is not None
assert state.session_id == "conv_abc"
assert state.conversation_id == "agy_conv"
assert state.active_turn_id == "turn_1"
assert not hasattr(state, "agy_pid")
def test_write_bridge_state_omits_agy_pid_key(bridge_dir: Path) -> None:
"""
Written state.json never contains an ``agy_pid`` key.
The field was removed, so a fresh write must not re-introduce it (a stray
key would mislead any tooling that still inspects the file).
:param bridge_dir: Isolated bridge directory fixture.
:returns: None.
"""
write_bridge_state(
bridge_dir,
AntigravityNativeBridgeState(session_id="conv_abc", conversation_id="agy_conv"),
)
raw = json.loads((bridge_dir / "state.json").read_text(encoding="utf-8"))
assert "agy_pid" not in raw
# ---------------------------------------------------------------------------
# read_bridge_state — None branches
# ---------------------------------------------------------------------------
def test_read_bridge_state_missing_dir_returns_none(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
Reading from a non-existent bridge directory returns None.
Guards startup safety: if the harness starts before the wrapper has
written state, the caller must gracefully wait rather than crash.
"""
monkeypatch.setattr(_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
missing = prepare_bridge_dir("never_written")
# Don't write any state — the directory exists but state.json does not.
assert read_bridge_state(missing) is None
def test_read_bridge_state_corrupt_json_returns_none(bridge_dir: Path) -> None:
"""
Corrupt JSON in state.json returns None, not an exception.
Guards partial-write resilience: if the file is truncated mid-write
the reader must degrade gracefully rather than crash the harness.
"""
(bridge_dir / "state.json").write_text("{not valid json", encoding="utf-8")
assert read_bridge_state(bridge_dir) is None
def test_read_bridge_state_missing_required_fields_returns_none(bridge_dir: Path) -> None:
"""
JSON missing required fields returns None.
Guards schema enforcement: an incomplete state (e.g. old format) must
not be returned as a partially-valid object that causes attribute errors.
"""
(bridge_dir / "state.json").write_text('{"session_id": "conv_abc"}\n', encoding="utf-8")
assert read_bridge_state(bridge_dir) is None
def test_read_bridge_state_missing_conversation_id_returns_none(bridge_dir: Path) -> None:
"""
JSON missing conversation_id returns None.
conversation_id is required (the forwarder/resume both depend on it); a
state lacking it must be rejected rather than returned with an empty id.
"""
(bridge_dir / "state.json").write_text(
json.dumps({"session_id": "conv_abc", "active_turn_id": None}) + "\n",
encoding="utf-8",
)
assert read_bridge_state(bridge_dir) is None
def test_read_bridge_state_empty_conversation_id_returns_none(bridge_dir: Path) -> None:
"""
An empty-string conversation_id returns None.
Guards the non-empty contract: an empty id never names a real agy brain
dir, so it must be rejected rather than handed to resume.
"""
(bridge_dir / "state.json").write_text(
json.dumps(
{
"session_id": "conv_abc",
"conversation_id": "",
"active_turn_id": None,
}
)
+ "\n",
encoding="utf-8",
)
assert read_bridge_state(bridge_dir) is None
def test_read_bridge_state_ignores_legacy_sidecar_fields(bridge_dir: Path) -> None:
"""
Legacy state.json carrying removed fields still reads back via the current
(reduced) schema.
Guards forward-compat across the Task 12 cutover: a state file written by a
prior build must not crash the reader. This covers the removed sidecar_port /
data_dir fields AND the durable read cursor (forwarded_step_index /
forwarded_steps) the transcript forwarder persisted before the RPC reader
superseded it — all obsolete keys are simply ignored.
"""
(bridge_dir / "state.json").write_text(
json.dumps(
{
"session_id": "conv_abc",
"sidecar_port": 9000,
"conversation_id": "agy_conv",
"data_dir": "/tmp/data",
"active_turn_id": None,
# Retired durable cursor keys (Task 12 cutover): a forwarder-era
# state.json carries these; the reader must tolerate + drop them.
"forwarded_step_index": 14,
"forwarded_steps": [0, 2, 14],
}
)
+ "\n",
encoding="utf-8",
)
state = read_bridge_state(bridge_dir)
assert state is not None
assert state.session_id == "conv_abc"
assert state.conversation_id == "agy_conv"
assert state.active_turn_id is None
# The retired cursor fields are gone from the dataclass entirely.
assert not hasattr(state, "forwarded_step_index")
assert not hasattr(state, "forwarded_steps")
# ---------------------------------------------------------------------------
# build_antigravity_native_spawn_env
# ---------------------------------------------------------------------------
def test_build_spawn_env_sets_both_env_vars(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
build_antigravity_native_spawn_env sets both required env vars.
The harness executor depends on both env vars being present to locate the
bridge directory and correlate the request session. A missing var would
leave the executor unable to read state or match the session.
"""
monkeypatch.setattr(_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
env = build_antigravity_native_spawn_env("conv_abc123")
assert ANTIGRAVITY_NATIVE_BRIDGE_DIR_ENV_VAR in env
assert ANTIGRAVITY_NATIVE_REQUEST_SESSION_ID_ENV_VAR in env
assert env[ANTIGRAVITY_NATIVE_REQUEST_SESSION_ID_ENV_VAR] == "conv_abc123"
def test_build_spawn_env_bridge_id_defaults_to_conversation_id(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
When bridge_id is None, the conversation_id is used as the bridge_id.
Guards the default behaviour: the bridge dir must be deterministic even
when no explicit bridge_id label is attached to the conversation.
"""
monkeypatch.setattr(_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
env_default = build_antigravity_native_spawn_env("conv_abc123")
env_explicit = build_antigravity_native_spawn_env("conv_abc123", bridge_id="conv_abc123")
assert (
env_default[ANTIGRAVITY_NATIVE_BRIDGE_DIR_ENV_VAR]
== env_explicit[ANTIGRAVITY_NATIVE_BRIDGE_DIR_ENV_VAR]
)
def test_build_spawn_env_explicit_bridge_id_differs(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
An explicit bridge_id produces a different bridge dir than the conversation_id.
Guards bridge isolation: two conversations sharing a bridge_id (label-based
routing) must resolve to the same directory, while distinct ids must not
collide.
"""
monkeypatch.setattr(_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
env_conv = build_antigravity_native_spawn_env("conv_abc123")
env_bridge = build_antigravity_native_spawn_env("conv_abc123", bridge_id="bridge_xyz")
assert (
env_conv[ANTIGRAVITY_NATIVE_BRIDGE_DIR_ENV_VAR]
!= env_bridge[ANTIGRAVITY_NATIVE_BRIDGE_DIR_ENV_VAR]
)
# ---------------------------------------------------------------------------
# prepare_bridge_dir permissions
# ---------------------------------------------------------------------------
def test_prepare_bridge_dir_creates_0o700_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""
prepare_bridge_dir creates the directory with mode 0o700.
Guards filesystem privacy: bridge dirs contain auth tokens and session
ids. A wider mode (e.g. 0o755) would expose those to other local users.
"""
import stat
monkeypatch.setattr(_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
bd = prepare_bridge_dir("bridge_perms_test")
assert bd.is_dir()
mode = stat.S_IMODE(bd.stat().st_mode)
assert mode == 0o700
# ---------------------------------------------------------------------------
# update_conversation_id
# ---------------------------------------------------------------------------
def test_update_conversation_id_mutates_correctly(bridge_dir: Path) -> None:
"""
update_conversation_id updates the conversation_id and active_turn_id.
Guards conversation rotation: when agy creates a fresh conversation while
the Omnigent session stays the same, the bridge must reflect the new id.
"""
_seed_active_turn(bridge_dir, "turn_old")
assert update_conversation_id(bridge_dir, "agy_conv_new", active_turn_id="turn_fresh") is True
state = read_bridge_state(bridge_dir)
assert state is not None
assert state.conversation_id == "agy_conv_new"
assert state.active_turn_id == "turn_fresh"
def test_update_conversation_id_clears_active_turn_by_default(bridge_dir: Path) -> None:
"""
update_conversation_id with no active_turn_id clears the active turn.
Guards the default: a new conversation starts with no active turn unless
the caller explicitly supplies one.
"""
_seed_active_turn(bridge_dir, "turn_old")
assert update_conversation_id(bridge_dir, "agy_conv_new") is True
state = read_bridge_state(bridge_dir)
assert state is not None
assert state.conversation_id == "agy_conv_new"
assert state.active_turn_id is None
def test_update_conversation_id_returns_false_and_warns_when_no_state(
bridge_dir: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""
update_conversation_id returns False and WARNs when there is no state.
Guards observability (Fix C): on a missing/invalid state file the real
cascade id must NOT be silently dropped — the function returns ``False`` and
logs a WARNING naming the dropped id so the cold-start caller can report that
the reader will stay bound to the ``agy_conv_*`` placeholder.
"""
with caplog.at_level(logging.WARNING, logger="omnigent.antigravity_native_bridge"):
result = update_conversation_id(bridge_dir, "agy_conv_new") # empty bridge dir
assert result is False
assert read_bridge_state(bridge_dir) is None
# The WARNING names the dropped conversation id.
assert any(
record.levelno == logging.WARNING and "agy_conv_new" in record.getMessage()
for record in caplog.records
)
# ---------------------------------------------------------------------------
# clear_bridge_state
# ---------------------------------------------------------------------------
def test_clear_bridge_state_removes_state(bridge_dir: Path) -> None:
"""
clear_bridge_state removes state.json so read_bridge_state returns None.
Guards stale-state cleanup: before a new agy launch the old state must
be removed so the harness waits for the new state instead of reading
stale sidecar coordinates.
"""
_seed_active_turn(bridge_dir, None)
assert read_bridge_state(bridge_dir) is not None
clear_bridge_state(bridge_dir)
assert read_bridge_state(bridge_dir) is None
def test_clear_bridge_state_noop_when_absent(bridge_dir: Path) -> None:
"""
clear_bridge_state is a no-op when state.json does not exist.
Guards idempotency: calling clear before the first write must not raise.
"""
clear_bridge_state(bridge_dir) # must not raise
# ---------------------------------------------------------------------------
# Onboarding marker (ensure_agy_onboarding_complete)
# ---------------------------------------------------------------------------
_COMPLETE_STATE = {
"consumerOnboardingComplete": True,
"enterpriseOnboardingComplete": False,
"onboardingComplete": True,
}
@pytest.fixture
def agy_marker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""
Redirect the agy onboarding marker into an isolated temp HOME.
:param tmp_path: pytest temp directory.
:param monkeypatch: pytest monkeypatch fixture.
:returns: The (not-yet-created) marker path under ``tmp_path``.
"""
marker = tmp_path / ".gemini" / "antigravity-cli" / "cache" / "onboarding.json"
monkeypatch.setattr(_mod, "_AGY_ONBOARDING_MARKER", marker)
return marker
def test_ensure_onboarding_writes_marker_and_parents_when_absent(agy_marker: Path) -> None:
"""
A fresh host (no marker, no cache dir) gets the complete-state file written.
Guards the core wizard-suppression: agy reads ``onboardingComplete: true`` and
skips the first-run TUI, and the nested ``cache/`` parents are created.
"""
assert not agy_marker.exists()
ensure_agy_onboarding_complete()
assert agy_marker.is_file()
assert json.loads(agy_marker.read_text(encoding="utf-8")) == _COMPLETE_STATE
def test_ensure_onboarding_is_idempotent_no_rewrite(agy_marker: Path) -> None:
"""
When all three keys are already complete the file is left byte-for-byte intact.
Pre-write a non-canonical encoding (custom key order, no trailing newline); a
returning user's agy state must not be churned, so an early return — not a
re-serialize — is required.
"""
agy_marker.parent.mkdir(parents=True, exist_ok=True)
original = '{"onboardingComplete": true, "consumerOnboardingComplete": true, "enterpriseOnboardingComplete": false}' # noqa: E501
agy_marker.write_text(original, encoding="utf-8")
ensure_agy_onboarding_complete()
assert agy_marker.read_text(encoding="utf-8") == original
def test_ensure_onboarding_preserves_unknown_keys_and_completes(agy_marker: Path) -> None:
"""
A partial/older marker is upgraded: unknown keys survive, the gate flips True.
"""
agy_marker.parent.mkdir(parents=True, exist_ok=True)
agy_marker.write_text(json.dumps({"custom": 7, "onboardingComplete": False}), encoding="utf-8")
ensure_agy_onboarding_complete()
result = json.loads(agy_marker.read_text(encoding="utf-8"))
assert result["custom"] == 7
assert result["onboardingComplete"] is True
assert result["consumerOnboardingComplete"] is True
assert result["enterpriseOnboardingComplete"] is False
def test_ensure_onboarding_overwrites_corrupt_file(agy_marker: Path) -> None:
"""
A non-JSON marker (regenerable, non-secret cache) is overwritten, not raised on.
"""
agy_marker.parent.mkdir(parents=True, exist_ok=True)
agy_marker.write_text("not json {{{", encoding="utf-8")
ensure_agy_onboarding_complete()
assert json.loads(agy_marker.read_text(encoding="utf-8")) == _COMPLETE_STATE
def test_ensure_onboarding_overwrites_non_object_json(agy_marker: Path) -> None:
"""
Valid-but-non-object JSON (e.g. a list) is treated as absent and replaced.
"""
agy_marker.parent.mkdir(parents=True, exist_ok=True)
agy_marker.write_text("[1, 2, 3]", encoding="utf-8")
ensure_agy_onboarding_complete()
assert json.loads(agy_marker.read_text(encoding="utf-8")) == _COMPLETE_STATE
def test_ensure_onboarding_normalises_numeric_truthy_values(agy_marker: Path) -> None:
"""
A marker storing numeric 1/0 (which ``==`` True/False in Python) is rewritten
to real JSON booleans — the early-return uses identity, not equality, so a
numeric file is not mistaken for the boolean complete-state.
"""
agy_marker.parent.mkdir(parents=True, exist_ok=True)
agy_marker.write_text(
json.dumps(
{
"consumerOnboardingComplete": 1,
"enterpriseOnboardingComplete": 0,
"onboardingComplete": 1,
}
),
encoding="utf-8",
)
ensure_agy_onboarding_complete()
result = json.loads(agy_marker.read_text(encoding="utf-8"))
assert result["onboardingComplete"] is True
assert result["consumerOnboardingComplete"] is True
assert result["enterpriseOnboardingComplete"] is False
def test_ensure_onboarding_raises_when_marker_dir_unwritable(agy_marker: Path) -> None:
"""
An un-creatable marker directory surfaces OSError (fail-loud), not a silent
skip — a missing marker would otherwise hang agy on the wizard.
"""
# Put a regular FILE where the ``cache`` directory must be so mkdir() fails.
agy_marker.parent.parent.mkdir(parents=True, exist_ok=True)
agy_marker.parent.write_text("not a dir", encoding="utf-8")
with pytest.raises(OSError):
ensure_agy_onboarding_complete()
def test_ensure_onboarding_overwrites_non_utf8_file(agy_marker: Path) -> None:
"""
A non-UTF-8 marker is treated as corrupt and regenerated, not raised on —
UnicodeDecodeError is a ValueError, caught alongside JSONDecodeError.
"""
agy_marker.parent.mkdir(parents=True, exist_ok=True)
agy_marker.write_bytes(b"\xff\xfe\x00 not utf-8 \x80\x81")
ensure_agy_onboarding_complete()
assert json.loads(agy_marker.read_text(encoding="utf-8")) == _COMPLETE_STATE
# ---------------------------------------------------------------------------
# tmux target advertisement (write_tmux_target / read_tmux_info)
# ---------------------------------------------------------------------------
def test_write_read_tmux_target_roundtrip(tmp_path: Path) -> None:
"""A written tmux target reads back with its socket path and pane target."""
bridge_dir = tmp_path / "bridge"
write_tmux_target(
bridge_dir,
socket_path=Path("/tmp/omnigent-x/tmux.sock"),
tmux_target="main",
)
info = read_tmux_info(bridge_dir)
assert info == {"socket_path": "/tmp/omnigent-x/tmux.sock", "tmux_target": "main"}
def test_read_tmux_info_missing_returns_none(tmp_path: Path) -> None:
"""A bridge dir with no ``tmux.json`` yields ``None`` (not an error)."""
assert read_tmux_info(tmp_path / "bridge") is None
def test_read_tmux_info_rejects_malformed_json(tmp_path: Path) -> None:
"""A corrupt ``tmux.json`` is treated as absent rather than raising."""
bridge_dir = prepare_bridge_dir("bridge_malformed")
(bridge_dir / "tmux.json").write_text("{not json", encoding="utf-8")
assert read_tmux_info(bridge_dir) is None
def test_read_tmux_info_rejects_missing_fields(tmp_path: Path) -> None:
"""A ``tmux.json`` lacking a non-empty target/socket is rejected."""
bridge_dir = prepare_bridge_dir("bridge_partial")
(bridge_dir / "tmux.json").write_text(json.dumps({"socket_path": "/s"}), encoding="utf-8")
assert read_tmux_info(bridge_dir) is None
(bridge_dir / "tmux.json").write_text(
json.dumps({"socket_path": "", "tmux_target": "main"}), encoding="utf-8"
)
assert read_tmux_info(bridge_dir) is None
def test_clear_bridge_state_removes_tmux_json(tmp_path: Path) -> None:
"""
Clearing runtime state also drops the advertised tmux pane.
A relaunch opens a new pane (and, after a host restart, a new socket), so a
surviving ``tmux.json`` would let the executor bootstrap the first turn
against the prior run's pane.
"""
bridge_dir = prepare_bridge_dir("bridge_clear")
write_bridge_state(
bridge_dir,
AntigravityNativeBridgeState(session_id="conv_1", conversation_id="cid-1"),
)
write_tmux_target(bridge_dir, socket_path=Path("/tmp/a/tmux.sock"), tmux_target="main")
assert read_tmux_info(bridge_dir) is not None
clear_bridge_state(bridge_dir)
assert read_bridge_state(bridge_dir) is None
assert read_tmux_info(bridge_dir) is None
# ---------------------------------------------------------------------------
# Paste payload encoding + submit needle
# ---------------------------------------------------------------------------
def test_paste_payload_bytes_maps_newlines_to_cr_and_strips_controls() -> None:
"""
Newlines become CR (so the TUI keeps multi-line input as data under
bracketed paste), tabs are kept, and other control bytes are dropped (a
stray ESC would close the bracketed paste early).
"""
payload = _mod._paste_payload_bytes("a\nb\r\nc\td\x1b\x00e")
# \n and \r\n both collapse to a single CR; \t kept; ESC + NUL dropped.
assert payload == b"a\rb\rc\tde"
def test_submit_needle_uses_first_substantial_line() -> None:
"""The needle is the first line with >=4 non-space chars, capped at 24."""
needle = _mod._submit_needle(" \nHello there, this is a long first line")
assert needle == "Hello there, this is a l"
assert _mod._submit_needle("hi") == ""
# ---------------------------------------------------------------------------
# First-turn TUI delivery (inject_user_message_via_tui)
# ---------------------------------------------------------------------------
@pytest.fixture
def _fast_tmux_timeouts(monkeypatch: pytest.MonkeyPatch) -> None:
"""Shrink the TUI delivery polls/timeouts so subprocess tests run fast."""
monkeypatch.setattr(_mod, "_TMUX_POLL_INTERVAL_S", 0.001)
monkeypatch.setattr(_mod, "_PASTE_SETTLE_S", 0.0)
monkeypatch.setattr(_mod, "_PASTE_COMMIT_TIMEOUT_S", 0.3)
monkeypatch.setattr(_mod, "_SUBMIT_VERIFY_TIMEOUT_S", 0.1)
monkeypatch.setattr(_mod, "_VERIFY_PROBE_TIMEOUT_S", 0.05)
monkeypatch.setattr(_mod, "_VERIFY_RETRY_INTERVAL_S", 0.0)
monkeypatch.setattr(_mod, "_VERIFY_RETRY_TIMEOUT_S", 5.0)
def test_inject_user_message_via_tui_happy_path(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""
The delivery clears, pastes via buffer, and submits with a verified Enter.
The fake models agy's TUI: idle (``? for shortcuts``) before the paste, the
draft visible after the paste, and a running turn (``esc to cancel``) once
Enter submits — exercising the readiness, paste-commit, and submit-verify
gates that a static pane would stall.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
content = "Reply with exactly WORKING"
captured: list[list[str]] = []
loaded: list[bytes] = []
tui = {"pane": "> \n? for shortcuts"}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""Record delivery calls; drive the simulated agy input box."""
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "load-buffer" in cmd:
loaded.append(Path(cmd[-1]).read_bytes())
if "paste-buffer" in cmd:
tui["pane"] = f"> {content}\n? for shortcuts"
if cmd[-1] == "Enter":
tui["pane"] = "> \nesc to cancel"
captured.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message_via_tui(bridge_dir, content=content, timeout_s=0.5)
# C-a, C-k (clear), load-buffer, paste-buffer, Enter.
assert len(captured) == 5, f"expected 5 delivery calls, got {captured}"
clear_home, clear_kill, load, paste, submit = captured
assert clear_home[-1] == "C-a"
assert clear_kill[-1] == "C-k"
assert loaded == [b"Reply with exactly WORKING\r"]
assert load == [
"tmux",
"-S",
"/tmp/ex/tmux.sock",
"load-buffer",
"-b",
"omnigent-agy-paste",
load[-1], # the temp paste file path (unlinked after the paste)
]
assert paste == [
"tmux",
"-S",
"/tmp/ex/tmux.sock",
"paste-buffer",
"-p",
"-d",
"-b",
"omnigent-agy-paste",
"-t",
"main",
]
assert submit == ["tmux", "-S", "/tmp/ex/tmux.sock", "send-keys", "-t", "main", "Enter"]
def test_inject_user_message_via_tui_resends_enter_when_coalesced(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""
A first Enter folded into the paste burst is re-sent until the turn starts.
The fake leaves the pane idle after the first Enter (the submit was
swallowed) and starts the turn only on the second — the delivery must not
give up after one Enter.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
enters = {"n": 0}
tui = {"pane": "> \n? for shortcuts"}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""Activate the turn only on the second Enter."""
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = "> hi there\n? for shortcuts"
if cmd[-1] == "Enter":
enters["n"] += 1
if enters["n"] >= 2:
tui["pane"] = "> \nesc to cancel"
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message_via_tui(bridge_dir, content="hi there", timeout_s=0.5)
assert enters["n"] == 2, "expected the submit Enter to be re-sent once"
def test_inject_user_message_via_tui_accepts_changed_active_footer(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""
The submit is confirmed when the draft leaves the composer, even if the
running footer text differs from the known marker.
A future agy build could rename the running footer or a narrow pane could
truncate it; delivery must key off draft disappearance instead.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
content = "hello there"
tui = {"pane": "> \n? for shortcuts"}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""Idle until Enter; then a non-idle footer that is NOT the known marker."""
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = f"> {content}\n? for shortcuts"
if cmd[-1] == "Enter":
tui["pane"] = "> \n(generating response, press the cancel key to stop)"
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message_via_tui(bridge_dir, content=content, timeout_s=0.5)
def test_inject_user_message_via_tui_mid_turn_sends_one_enter(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""
A steer delivered while agy is mid-turn sends exactly one Enter, no re-send.
The footer is already ``esc to cancel`` (a turn is running), so the
idle->running verification is unavailable and a second Enter could queue a
spurious empty turn. The delivery must still pass readiness (active footer
accepted, not waiting for an idle footer that will not appear), paste, and
submit a single best-effort Enter without spinning the retry budget or
raising.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
enters = {"n": 0}
tui = {"pane": "> \nesc to cancel"}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""agy stays mid-turn, but the draft clears after Enter."""
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = "> steer me\nesc to cancel"
if cmd[-1] == "Enter":
enters["n"] += 1
tui["pane"] = "> \nesc to cancel"
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message_via_tui(bridge_dir, content="steer me", timeout_s=0.5)
assert enters["n"] == 1, "mid-turn submit must send exactly one Enter (no re-send)"
def test_inject_user_message_via_tui_ignores_transcript_echo_after_submit(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""
The submitted prompt remaining in transcript history is not a stuck draft.
Claude/Kiro-style verification must scope matching to agy's live composer.
After Enter, agy echoes the prompt above a fresh empty composer; matching the
whole pane would falsely retry Enter and could submit an empty follow-up.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
content = "Reply exactly: transcript echo ok"
enters = {"n": 0}
separator = "────────────────────────────────────────────────────────────────────────────────"
tui = {"pane": f"{separator}\n>\n{separator}\n? for shortcuts"}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""After Enter, keep the prompt only in transcript history."""
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = f"{separator}\n> {content}\n{separator}\n? for shortcuts"
if cmd[-1] == "Enter":
enters["n"] += 1
tui["pane"] = (
f"{separator}\n> {content}\n⣾ Generating...\n{separator}\n"
f">\n{separator}\nesc to cancel"
)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message_via_tui(bridge_dir, content=content, timeout_s=0.5)
assert enters["n"] == 1
def test_inject_user_message_via_tui_raises_when_target_never_advertised(
tmp_path: Path,
) -> None:
"""No ``tmux.json`` → a loud RuntimeError (the turn must not silently vanish)."""
with pytest.raises(RuntimeError, match="tmux target was not advertised"):
inject_user_message_via_tui(tmp_path / "bridge", content="hi", timeout_s=0.0)
def test_inject_user_message_via_tui_raises_when_session_dead(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""A dead tmux pane (agy TUI exited) fails fast with a restart hint."""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""has-session reports the pane is gone."""
del kwargs
return SimpleNamespace(returncode=1, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="no longer running"):
inject_user_message_via_tui(bridge_dir, content="hi", timeout_s=0.5)
def test_inject_user_message_via_tui_raises_when_session_dies_before_submit(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""
A TUI that exits between the readiness check and the submit fails fast.
The entry liveness check passes, but ``_submit_and_verify`` re-checks before
each Enter — so a pane that dies after the paste raises the specific
"exited before the message could be submitted" error instead of spinning the
full submit budget and then blaming paste-coalescing.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
has_session_calls = {"n": 0}
tui = {"pane": "> \n? for shortcuts"}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""Alive at the entry check; dead by the time the submit re-checks."""
del kwargs
if "has-session" in cmd:
has_session_calls["n"] += 1
# 1st call = entry gate (alive); later calls = per-submit re-check (dead).
return SimpleNamespace(returncode=0 if has_session_calls["n"] == 1 else 1, stdout="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = "> draft message\n? for shortcuts"
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="exited before the message could be submitted"):
inject_user_message_via_tui(bridge_dir, content="draft message", timeout_s=0.5)
def test_inject_user_message_via_tui_raises_when_paste_never_renders(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""A successful tmux paste command is not enough; the draft must render."""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""Pane stays empty even after paste-buffer returns successfully."""
del kwargs
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout="> \n? for shortcuts", stderr="")
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="did not render the pasted message"):
inject_user_message_via_tui(bridge_dir, content="draft message", timeout_s=0.5)
def test_inject_user_message_via_tui_raises_when_turn_never_starts(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""
If no turn ever starts after the submit attempts, the delivery raises.
The pasted draft stays visible forever (Enter never takes), so after the
bounded re-send budget the executor gets a clear error rather than a false
success.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
tui = {"pane": "> \n? for shortcuts"}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""Pane never leaves idle — the submit never starts a turn."""
del kwargs
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = "> draft message\n? for shortcuts"
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="draft is still visible"):
inject_user_message_via_tui(bridge_dir, content="draft message", timeout_s=0.5)
def test_inject_user_message_via_tui_rejects_empty_content(tmp_path: Path) -> None:
"""Empty content is a programming error, not something to type into the TUI."""
with pytest.raises(RuntimeError, match="non-empty content"):
inject_user_message_via_tui(tmp_path / "bridge", content="")
# ---------------------------------------------------------------------------
# Collapsed-paste draft detection
# ---------------------------------------------------------------------------
def test_draft_in_input_region_detects_collapsed_paste_placeholder() -> None:
"""
agy's collapsed-paste placeholder counts as a rendered draft.
Past ~13 line breaks agy replaces the pasted text with a single
``[Pasted text #N +M lines]`` row instead of echoing it, so the needle is
never visible in the composer. Without this the render gate reads a
correctly pasted multi-line prompt as "never rendered".
"""
baseline = "> "
pane = "> [Pasted text #1 +17 lines]\n? for shortcuts"
assert _mod._draft_in_input_region(pane, "Find the current weather", baseline)
# Once the placeholder leaves the composer the draft is gone (submit verified).
submitted = "> \n? for shortcuts"
assert not _mod._draft_in_input_region(submitted, "Find the current weather", baseline)
def test_inject_user_message_via_tui_submits_a_collapsed_paste(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""
A multi-line prompt agy collapses is still submitted, not reported as lost.
Reproduces the polly sub-agent dispatch: the task prompt is long enough that
agy shows only the placeholder, which previously failed the render gate and
raised "did not render the pasted message" while the draft sat in the
composer.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
content = "\n".join(f"Find the current weather line {i}" for i in range(1, 19))
tui = {"pane": "> \n? for shortcuts"}
enters = {"n": 0}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""agy collapses the paste, then starts a turn on Enter."""
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = "> [Pasted text #1 +18 lines]\n? for shortcuts"
if cmd[-1] == "Enter":
enters["n"] += 1
tui["pane"] = "> \nesc to cancel"
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message_via_tui(bridge_dir, content=content, timeout_s=0.5)
assert enters["n"] == 1, "the collapsed draft should submit on the first Enter"
# ---------------------------------------------------------------------------
# Account-verification re-delivery
# ---------------------------------------------------------------------------
_VERIFYING_PANE = (
"⚠ Verifying your account...\n"
" ⎿ We're finishing verifying your account eligibility.\n"
" This usually takes a moment. Please try again shortly.\n"
"> \n? for shortcuts"
)
def test_inject_user_message_via_tui_redelivers_while_account_verifying(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""
A turn agy answers with its account-verification notice is re-delivered.
agy consumes a turn submitted before its startup eligibility check settles —
the draft leaves the composer, so the submit verifies — and renders the
notice instead of starting a cascade. Without a re-delivery the turn is lost
and the terminal sits idle, which is what a programmatically dispatched
sub-agent hits on every launch.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
content = "do the thing"
tui = {"pane": "> \n? for shortcuts"}
pastes = {"n": 0}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""Reject the first submit with the notice; start a turn on the second."""
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
pastes["n"] += 1
tui["pane"] = f"> {content}\n? for shortcuts"
if cmd[-1] == "Enter":
tui["pane"] = _VERIFYING_PANE if pastes["n"] == 1 else "> \nesc to cancel"
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message_via_tui(bridge_dir, content=content, timeout_s=0.5)
assert pastes["n"] == 2, "expected the rejected turn to be re-delivered once"
def test_inject_user_message_via_tui_raises_when_account_verification_never_clears(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""
Re-delivery is bounded: a never-clearing notice surfaces a clear error.
Retrying forever would hang the turn; the executor needs a failure it can
surface so the user learns the account is not usable yet.
"""
monkeypatch.setattr(_mod, "_VERIFY_RETRY_TIMEOUT_S", 0.0)
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
content = "do the thing"
tui = {"pane": "> \n? for shortcuts"}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""Every submit is answered with the verification notice."""
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = f"> {content}\n? for shortcuts"
if cmd[-1] == "Enter":
tui["pane"] = _VERIFYING_PANE
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="still verifying the Antigravity account"):
inject_user_message_via_tui(bridge_dir, content=content, timeout_s=0.5)
def test_inject_user_message_via_tui_does_not_redeliver_an_accepted_turn(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""
A stale notice left on screen from a prior attempt never causes a duplicate.
agy clears the notice only when a turn actually starts, so the running-turn
marker must win over notice text still visible in the same capture.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
content = "do the thing"
tui = {"pane": "> \n? for shortcuts"}
pastes = {"n": 0}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""Start a turn while the previous attempt's notice is still rendered."""
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
pastes["n"] += 1
tui["pane"] = f"> {content}\n? for shortcuts"
if cmd[-1] == "Enter":
tui["pane"] = _VERIFYING_PANE.replace("? for shortcuts", "esc to cancel")
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message_via_tui(bridge_dir, content=content, timeout_s=0.5)
assert pastes["n"] == 1, "a running turn must not be re-delivered"
def test_inject_user_message_via_tui_ignores_stale_notice_with_changed_active_footer(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""A renamed active footer still prevents a stale-notice duplicate."""
monkeypatch.setattr(_mod, "_VERIFY_RETRY_TIMEOUT_S", 0.2)
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
content = "do the thing"
tui = {"pane": "> \n? for shortcuts"}
pastes = {"n": 0}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""Reject once, then accept while the stale notice remains visible."""
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
pastes["n"] += 1
tui["pane"] = f"> {content}\n? for shortcuts"
if cmd[-1] == "Enter":
tui["pane"] = (
_VERIFYING_PANE
if pastes["n"] == 1
else _VERIFYING_PANE.replace(
"? for shortcuts", "(generating response, press the cancel key to stop)"
)
)
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message_via_tui(bridge_dir, content=content, timeout_s=0.5)
assert pastes["n"] == 2, "the accepted retry must not be delivered again"
# ---------------------------------------------------------------------------
# Composer-region detection robustness (#1598 review follow-ups)
# ---------------------------------------------------------------------------
def test_agy_separator_line_accepts_pure_and_decorated_rules() -> None:
"""Separator detection accepts a pure ─ rule and corner/join-decorated rules.
agy 1.0.14 renders a pure ``─`` composer rule, but a future build could frame
it with box-drawing corners; detection must tolerate that without matching
ordinary text or a short dash run.
"""
assert _mod._agy_separator_line("─" * 60)
assert _mod._agy_separator_line("╭" + "─" * 40 + "╮")
assert _mod._agy_separator_line("├" + "─" * 20 + "┤")
# Not separators: ordinary text, ASCII dashes, and < 8 rule chars.
assert not _mod._agy_separator_line("> Generating the answer now")
assert not _mod._agy_separator_line("--- short ---")
assert not _mod._agy_separator_line("─────")
def test_draft_candidate_lines_keep_prompt_text_with_status_words() -> None:
"""A draft line carrying the ``>`` prompt is kept even if it contains a status word.
The chrome filters (``Generating``/idle/active markers) must apply only to
non-prompt rows; otherwise a legitimate message whose first line contains such
a word is filtered out and the turn is misread as "never rendered".
"""
region = "> Generating a fix for the esc to cancel bug\n? for shortcuts"
candidates = _mod._agy_draft_candidate_lines(region)
assert "Generating a fix for the esc to cancel bug" in candidates
# The non-prompt idle footer row is still filtered.
assert "? for shortcuts" not in candidates
def test_draft_in_input_region_keeps_prompt_text_with_status_words() -> None:
"""A draft whose first line contains 'Generating' is detected, not hard-failed."""
sep = "─" * 60
baseline = _mod._agy_input_region(f"{sep}\n>\n{sep}\n? for shortcuts")
content = "Generating the migration is slow, please fix it"
needle = _mod._submit_needle(content)
pane = f"{sep}\n> {content}\n{sep}\n? for shortcuts"
assert _mod._draft_in_input_region(pane, needle, baseline)
def test_draft_in_input_region_matches_short_message_by_composer_change() -> None:
"""An empty needle (short message) is detected by a changed composer, not text.
A draft in a composer that differs from the pre-paste baseline counts as
present; the unchanged baseline (or a fresh empty composer after submit) does
not — so short messages are render/submit-verified instead of submitted blind.
"""
sep = "─" * 60
baseline = _mod._agy_input_region(f"{sep}\n>\n{sep}\n? for shortcuts")
pane_with_draft = f"{sep}\n> ok\n{sep}\n? for shortcuts"
pane_after_submit = f"{sep}\n> ok\n{sep}\n>\n{sep}\nesc to cancel"
assert _mod._draft_in_input_region(pane_with_draft, "", baseline)
assert not _mod._draft_in_input_region(pane_after_submit, "", baseline)
def test_format_pane_debug_tail_redacts_email_and_secrets() -> None:
"""The diagnostic pane tail redacts emails and common secret shapes (#1598)."""
pane = (
"signed in as alice@example.com\n"
"key sk-ABCDEF0123456789ABCDEF\n"
"export GH=ghp_ABCDEFGHIJ0123456789ABCDEFGHIJ\n"
"Authorization: Bearer abcdef123456\n"
"> draft text"
)
out = _mod._format_pane_debug_tail(pane)
assert "alice@example.com" not in out
assert "sk-ABCDEF0123456789ABCDEF" not in out
assert "ghp_ABCDEFGHIJ0123456789ABCDEFGHIJ" not in out
assert "[REDACTED_EMAIL]" in out
assert "[REDACTED_SECRET]" in out
assert "> draft text" in out # non-secret context is preserved
assert _mod._format_pane_debug_tail("") == "<empty pane>"
def test_inject_user_message_via_tui_delivers_short_message(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""A short message with no stable needle ('ok') is render- and submit-verified.
The empty-needle path must not submit blind: the draft is confirmed to render
by composer change, and submission is confirmed by the draft clearing — so a
legitimate short turn is delivered with exactly one verified Enter.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
enters = {"n": 0}
tui = {"pane": "> \n? for shortcuts"}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""Idle, draft after paste, composer clears after Enter."""
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = "> ok\n? for shortcuts"
if cmd[-1] == "Enter":
enters["n"] += 1
tui["pane"] = "> \n? for shortcuts"
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
inject_user_message_via_tui(bridge_dir, content="ok", timeout_s=0.5)
assert enters["n"] == 1
def test_inject_user_message_via_tui_short_message_raises_when_not_submitted(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
_fast_tmux_timeouts: None,
) -> None:
"""A short message whose draft never clears fails loudly, not silently.
Regression guard for the empty-needle path: previously a no-needle message was
submitted with a single unverified Enter, so a folded Enter left it stuck in
the composer and the turn was silently lost. It must now raise.
"""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
tui = {"pane": "> \n? for shortcuts"}
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
"""Draft renders but never clears (Enter folded into the paste burst)."""
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
if "capture-pane" in cmd:
return SimpleNamespace(returncode=0, stdout=tui["pane"], stderr="")
if "paste-buffer" in cmd:
tui["pane"] = "> ok\n? for shortcuts"
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="draft is still visible"):
inject_user_message_via_tui(bridge_dir, content="ok", timeout_s=0.5)
# ---------------------------------------------------------------------------
# Omnigent MCP relay wiring (sys_* tools) — #1194
# ---------------------------------------------------------------------------
def test_build_mcp_config_registers_omnigent_relay(tmp_path: Path) -> None:
"""build_mcp_config emits the shared serve-mcp relay command + enabledTools."""
config = build_mcp_config(tmp_path, python_executable="python-test")
server = config["mcpServers"]["omnigent"]
assert server["command"] == "python-test"
assert server["args"] == [
"-I",
"-m",
"omnigent.claude_native_bridge",
"serve-mcp",
"--bridge-dir",
str(tmp_path),
]
# agy's auto-approve allowlist is the enabledTools key (not cursor's autoApprove).
assert "sys_session_send" in server["enabledTools"]
assert "sys_os_shell" in server["enabledTools"]
assert "sys_terminal_launch" in server["enabledTools"]
assert "sys_list_models" in server["enabledTools"]
assert server["enabledTools"] == sorted(server["enabledTools"])
assert server["env"]["TMPDIR"]
# The relay's HOME is pinned to the runner's real home so its bridge-root
# validation matches where the bridge dir lives, even if a future agy launch
# path customizes process environment.
assert server["env"]["HOME"] == str(Path.home())
def test_build_mcp_config_defaults_python_to_current_interpreter(tmp_path: Path) -> None:
"""Omitting python_executable uses sys.executable so the relay runs in our venv."""
import sys
server = build_mcp_config(tmp_path)["mcpServers"]["omnigent"]
assert server["command"] == sys.executable
def test_write_mcp_config_targets_isolated_agy_gemini_dir(tmp_path: Path) -> None:
"""write_mcp_config writes into the isolated agy Gemini dir, not ~/.gemini."""
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
path = write_mcp_config(bridge_dir, python_executable="python-test")
# The config lands under the isolated --gemini_dir config path, so the user's
# real ~/.gemini is untouched.
assert path == agy_gemini_dir(bridge_dir) / "config" / "mcp_config.json"
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload["mcpServers"]["omnigent"]["command"] == "python-test"
# The bridge token the shared relay requires is written into the bridge dir.
assert json.loads((bridge_dir / "bridge.json").read_text(encoding="utf-8"))["token"]
def test_write_mcp_bridge_config_is_idempotent(tmp_path: Path) -> None:
"""A second write keeps the existing token so a live relay is not re-tokened."""
write_mcp_bridge_config(tmp_path)
first = (tmp_path / "bridge.json").read_text(encoding="utf-8")
write_mcp_bridge_config(tmp_path)
assert (tmp_path / "bridge.json").read_text(encoding="utf-8") == first
def test_seed_isolated_agy_home_seeds_platform_credentials_and_state(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""seed_isolated_agy_home copies platform OAuth markers + state."""
fake_home = tmp_path / "real-home"
(fake_home / ".gemini" / "antigravity-cli").mkdir(parents=True)
(fake_home / ".gemini" / "oauth_creds.json").write_text(
'{"access_token":"mac-token"}', encoding="utf-8"
)
(fake_home / ".gemini" / "installation_id").write_text("root-install-id", encoding="utf-8")
(fake_home / ".gemini" / "antigravity-cli" / "antigravity-oauth-token").write_text(
"linux-token", encoding="utf-8"
)
(fake_home / ".gemini" / "antigravity-cli" / "installation_id").write_text(
"cli-install-id", encoding="utf-8"
)
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home))
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
env = seed_isolated_agy_home(bridge_dir)
iso_gemini = agy_gemini_dir(bridge_dir)
assert env == {}
# OAuth credentials are COPIED (never moved) into the isolated tree.
macos_token = iso_gemini / "oauth_creds.json"
linux_token = iso_gemini / "antigravity-cli" / "antigravity-oauth-token"
assert macos_token.read_text(encoding="utf-8") == '{"access_token":"mac-token"}'
assert linux_token.read_text(encoding="utf-8") == "linux-token"
assert (iso_gemini / "installation_id").read_text(encoding="utf-8") == "root-install-id"
assert (iso_gemini / "antigravity-cli" / "installation_id").read_text(
encoding="utf-8"
) == "cli-install-id"
# The real credential files are left in place.
assert (fake_home / ".gemini" / "oauth_creds.json").read_text(encoding="utf-8") == (
'{"access_token":"mac-token"}'
)
assert (fake_home / ".gemini" / "antigravity-cli" / "antigravity-oauth-token").read_text(
encoding="utf-8"
) == "linux-token"
# Onboarding + migration markers seeded so a headless launch never blocks.
onboarding = iso_gemini / "antigravity-cli" / "cache" / "onboarding.json"
assert json.loads(onboarding.read_text(encoding="utf-8"))["onboardingComplete"] is True
assert (iso_gemini / "config" / ".migrated").is_file()
def test_seed_isolated_agy_home_trusts_workspace_in_isolated_settings(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Workspace trust is seeded under isolated --gemini_dir, not real ~/.gemini."""
fake_home = tmp_path / "real-home"
real_settings = fake_home / ".gemini" / "antigravity-cli" / "settings.json"
real_settings.parent.mkdir(parents=True)
real_settings.write_text(
json.dumps({"colorScheme": "dark", "trustedWorkspaces": ["/real/repo"]}),
encoding="utf-8",
)
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home))
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
workspace = tmp_path / "worktrees" / "feature-x"
workspace.mkdir(parents=True)
iso_gemini = agy_gemini_dir(bridge_dir)
isolated_settings = iso_gemini / "antigravity-cli" / "settings.json"
isolated_settings.parent.mkdir(parents=True)
isolated_settings.write_text(
json.dumps({"colorScheme": "solarized dark", "trustedWorkspaces": ["/existing"]}),
encoding="utf-8",
)
seed_isolated_agy_home(bridge_dir, trusted_workspace=workspace)
isolated = json.loads(isolated_settings.read_text(encoding="utf-8"))
assert isolated["colorScheme"] == "solarized dark"
assert isolated["trustedWorkspaces"] == ["/existing", str(workspace.resolve())]
# The user's real agy settings are never modified by this bridge-scoped seed.
assert json.loads(real_settings.read_text(encoding="utf-8")) == {
"colorScheme": "dark",
"trustedWorkspaces": ["/real/repo"],
}
def test_seed_isolated_agy_home_tolerates_missing_credential(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A missing OAuth token only means agy re-auths — never a hard failure."""
fake_home = tmp_path / "real-home"
fake_home.mkdir()
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home))
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
env = seed_isolated_agy_home(bridge_dir)
iso_gemini = agy_gemini_dir(bridge_dir)
assert env == {}
# No token copied (none existed), but the isolated Gemini dir + markers still exist.
assert not (iso_gemini / "antigravity-cli" / "antigravity-oauth-token").exists()
assert (iso_gemini / "antigravity-cli" / "cache" / "onboarding.json").is_file()
def test_seed_isolated_agy_home_exposes_user_plugins(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The user's imported plugins (skills + hooks) are visible under --gemini_dir."""
fake_home = tmp_path / "real-home"
real_config = fake_home / ".gemini" / "config"
(real_config / "plugins" / "superpowers" / "skills").mkdir(parents=True)
(real_config / "plugins" / "superpowers" / "skills" / "brainstorming.md").write_text(
"# brainstorming", encoding="utf-8"
)
manifest = json.dumps(
{"imports": [{"name": "superpowers", "components": ["skills", "hooks"]}]}
)
(real_config / "import_manifest.json").write_text(manifest, encoding="utf-8")
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home))
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
seed_isolated_agy_home(bridge_dir)
iso_config = agy_gemini_dir(bridge_dir) / "config"
# agy resolves plugins relative to --gemini_dir, so both the manifest and the
# plugin payload must be reachable there or the session lists zero skills.
assert json.loads((iso_config / "import_manifest.json").read_text(encoding="utf-8")) == (
json.loads(manifest)
)
seeded_skill = iso_config / "plugins" / "superpowers" / "skills" / "brainstorming.md"
assert seeded_skill.read_text(encoding="utf-8") == "# brainstorming"
def test_seed_isolated_agy_home_does_not_copy_plugin_payload(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Plugins are linked, not copied, so plugin updates are picked up live."""
fake_home = tmp_path / "real-home"
real_plugins = fake_home / ".gemini" / "config" / "plugins"
(real_plugins / "superpowers").mkdir(parents=True)
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home))
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
seed_isolated_agy_home(bridge_dir)
iso_plugins = agy_gemini_dir(bridge_dir) / "config" / "plugins"
assert iso_plugins.is_symlink()
assert iso_plugins.resolve() == real_plugins.resolve()
# A plugin installed/updated after the session started is still visible.
(real_plugins / "later").mkdir()
assert (iso_plugins / "later").is_dir()
def test_seed_isolated_agy_home_plugin_seed_is_idempotent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Re-seeding an existing bridge dir does not fail on the existing link."""
fake_home = tmp_path / "real-home"
(fake_home / ".gemini" / "config" / "plugins").mkdir(parents=True)
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home))
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
seed_isolated_agy_home(bridge_dir)
seed_isolated_agy_home(bridge_dir)
iso_plugins = agy_gemini_dir(bridge_dir) / "config" / "plugins"
assert iso_plugins.is_symlink()
def test_seed_isolated_agy_home_tolerates_absent_plugins(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A user with no imported plugins seeds cleanly — no link, no manifest."""
fake_home = tmp_path / "real-home"
(fake_home / ".gemini").mkdir(parents=True)
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home))
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
seed_isolated_agy_home(bridge_dir)
iso_config = agy_gemini_dir(bridge_dir) / "config"
assert not (iso_config / "plugins").exists()
assert not (iso_config / "import_manifest.json").exists()
# The rest of the seed still landed.
assert (iso_config / ".migrated").is_file()
def test_seed_isolated_agy_home_exposes_user_skill_dirs(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""agy's Global and Shared skill trees resolve under ``--gemini_dir``.
The ``/skills`` menu enumerates these from the REAL home, so a session that
cannot resolve them there would offer a skill agy then fails to expand.
"""
fake_home = tmp_path / "real-home"
real_global = fake_home / ".gemini" / "antigravity-cli" / "skills"
real_shared = fake_home / ".gemini" / "skills"
(real_global / "global-skill").mkdir(parents=True)
(real_global / "global-skill" / "SKILL.md").write_text("# global", encoding="utf-8")
(real_shared / "shared-skill").mkdir(parents=True)
(real_shared / "shared-skill" / "SKILL.md").write_text("# shared", encoding="utf-8")
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home))
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
seed_isolated_agy_home(bridge_dir)
iso_gemini = agy_gemini_dir(bridge_dir)
iso_global = iso_gemini / "antigravity-cli" / "skills"
iso_shared = iso_gemini / "skills"
# Linked, not copied: a skill authored mid-session is visible immediately.
assert iso_global.is_symlink() and iso_shared.is_symlink()
assert (iso_global / "global-skill" / "SKILL.md").read_text(encoding="utf-8") == "# global"
assert (iso_shared / "shared-skill" / "SKILL.md").read_text(encoding="utf-8") == "# shared"
(real_shared / "later").mkdir()
assert (iso_shared / "later").is_dir()
def test_seed_isolated_agy_home_skill_dir_seed_is_idempotent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Re-seeding an existing bridge dir does not fail on the existing links."""
fake_home = tmp_path / "real-home"
(fake_home / ".gemini" / "antigravity-cli" / "skills").mkdir(parents=True)
(fake_home / ".gemini" / "skills").mkdir(parents=True)
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home))
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
seed_isolated_agy_home(bridge_dir)
seed_isolated_agy_home(bridge_dir)
iso_gemini = agy_gemini_dir(bridge_dir)
assert (iso_gemini / "antigravity-cli" / "skills").is_symlink()
assert (iso_gemini / "skills").is_symlink()
def test_seed_isolated_agy_home_tolerates_absent_skill_dirs(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A user with no Global/Shared skills seeds cleanly — no links, no failure."""
fake_home = tmp_path / "real-home"
(fake_home / ".gemini").mkdir(parents=True)
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home))
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
seed_isolated_agy_home(bridge_dir)
iso_gemini = agy_gemini_dir(bridge_dir)
assert not (iso_gemini / "skills").exists()
assert not (iso_gemini / "antigravity-cli" / "skills").exists()
# agy owns ``antigravity-cli`` itself; seeding must not have clobbered it.
assert (iso_gemini / "antigravity-cli" / "cache" / "onboarding.json").is_file()
def test_seed_isolated_agy_home_skill_links_never_mutate_real_home(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Seeding links INTO the real skill trees; it never writes through them."""
fake_home = tmp_path / "real-home"
real_shared = fake_home / ".gemini" / "skills"
real_shared.mkdir(parents=True)
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home))
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
seed_isolated_agy_home(bridge_dir)
assert sorted(p.name for p in real_shared.iterdir()) == []
def test_agy_home_dir_is_under_bridge_dir(tmp_path: Path) -> None:
"""The isolated state parent is a child of the per-session bridge dir."""
bridge_dir = tmp_path / "bridge"
assert agy_home_dir(bridge_dir).parent == bridge_dir
def test_agy_gemini_dir_is_under_agy_home_dir(tmp_path: Path) -> None:
"""The isolated Gemini dir is passed to agy via --gemini_dir."""
bridge_dir = tmp_path / "bridge"
assert agy_gemini_dir(bridge_dir) == agy_home_dir(bridge_dir) / ".gemini"
def test_seeding_and_mcp_config_never_mutate_real_gemini_dir(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Linux non-regression: the user's real ``~/.gemini`` is read-only to setup.
This is the central promise of the ``--gemini_dir`` design: keeping the real
``HOME`` must NOT reintroduce the clobber/concurrency footgun the prior
isolated-HOME design avoided. Both setup steps that touch the real
``~/.gemini`` — ``seed_isolated_agy_home`` (COPIES the OAuth markers out) and
``write_mcp_config`` (writes the relay config) — must leave a fully populated
real ``~/.gemini`` (interactive-agy user with their own MCP config) byte-for-byte
untouched, writing only under the per-session isolated Gemini dir.
"""
fake_home = tmp_path / "real-home"
real_gemini = fake_home / ".gemini"
(real_gemini / "antigravity-cli").mkdir(parents=True)
(real_gemini / "config").mkdir(parents=True)
# Populate the real tree as a logged-in interactive-agy user would have it,
# including their OWN mcp_config.json the design must never clobber.
seeded_files = {
real_gemini / "oauth_creds.json": '{"access_token":"mac-token"}',
real_gemini / "installation_id": "root-install-id",
real_gemini / "antigravity-cli" / "antigravity-oauth-token": "linux-token",
real_gemini / "antigravity-cli" / "installation_id": "cli-install-id",
real_gemini / "config" / "mcp_config.json": '{"mcpServers":{"user_own":{}}}',
}
for path, body in seeded_files.items():
path.write_text(body, encoding="utf-8")
before = {path: path.read_text(encoding="utf-8") for path in seeded_files}
real_listing_before = sorted(
p.relative_to(real_gemini).as_posix() for p in real_gemini.rglob("*")
)
monkeypatch.setattr(Path, "home", classmethod(lambda _cls: fake_home))
bridge_dir = tmp_path / "bridge"
bridge_dir.mkdir()
seed_isolated_agy_home(bridge_dir)
write_mcp_config(bridge_dir, python_executable="python-test")
# Every real file is byte-for-byte unchanged: nothing copied OUT was mutated,
# and the user's own mcp_config.json was NOT overwritten by the relay config.
for path, original in before.items():
assert path.read_text(encoding="utf-8") == original
# No new files leaked into the real tree (e.g. a relay config under real config/).
real_listing_after = sorted(
p.relative_to(real_gemini).as_posix() for p in real_gemini.rglob("*")
)
assert real_listing_after == real_listing_before
# The relay config DID land in the isolated tree (positive control).
assert (agy_gemini_dir(bridge_dir) / "config" / "mcp_config.json").is_file()
# ---------------------------------------------------------------------------
# Interaction-prompt TUI delivery (send_interaction_keys_via_tui) — #1200
# ---------------------------------------------------------------------------
def test_send_interaction_keys_via_tui_sends_one_send_keys_invocation(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The verdict keys go out as ONE ``send-keys`` so digit + Enter stay together."""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
captured: list[list[str]] = []
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
captured.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
send_interaction_keys_via_tui(bridge_dir, "1", "Enter")
assert captured == [
["tmux", "-S", "/tmp/ex/tmux.sock", "send-keys", "-t", "main", "1", "Enter"]
]
def test_send_interaction_keys_via_tui_reject_sequence(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A reject verdict's ``4`` + Enter sequence is forwarded verbatim."""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
captured: list[list[str]] = []
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
del kwargs
if "has-session" in cmd:
return SimpleNamespace(returncode=0, stdout="", stderr="")
captured.append(cmd)
return SimpleNamespace(returncode=0, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
send_interaction_keys_via_tui(bridge_dir, "4", "Enter")
assert captured == [
["tmux", "-S", "/tmp/ex/tmux.sock", "send-keys", "-t", "main", "4", "Enter"]
]
def test_send_interaction_keys_via_tui_raises_without_target(tmp_path: Path) -> None:
"""No advertised tmux target → a clear RuntimeError (no silent drop)."""
with pytest.raises(RuntimeError, match="tmux target not advertised"):
send_interaction_keys_via_tui(tmp_path / "bridge", "1", "Enter")
def test_send_interaction_keys_via_tui_raises_when_pane_gone(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An exited agy pane → RuntimeError so the bridge logs the best-effort miss."""
bridge_dir = tmp_path / "bridge"
write_tmux_target(bridge_dir, socket_path=Path("/tmp/ex/tmux.sock"), tmux_target="main")
def _fake_run(cmd: list[str], **kwargs: object) -> SimpleNamespace:
del kwargs
# has-session reports the pane is gone (non-zero exit).
return SimpleNamespace(returncode=1, stdout="", stderr="")
monkeypatch.setattr("subprocess.run", _fake_run)
with pytest.raises(RuntimeError, match="no longer running"):
send_interaction_keys_via_tui(bridge_dir, "1", "Enter")
def test_send_interaction_keys_via_tui_rejects_empty_keys(tmp_path: Path) -> None:
"""No keys is a programming error, not an empty send-keys call."""
with pytest.raises(RuntimeError, match="at least one key"):
send_interaction_keys_via_tui(tmp_path / "bridge")
def _agy_settings_path(home: Path) -> Path:
return home / ".gemini" / "antigravity-cli" / "settings.json"
def test_ensure_agy_feedback_survey_disabled_creates_missing_settings(tmp_path: Path) -> None:
"""A home with no settings.json gets one carrying just the disable key."""
ensure_agy_feedback_survey_disabled(tmp_path)
assert json.loads(_agy_settings_path(tmp_path).read_text(encoding="utf-8")) == {
"showFeedbackSurvey": False
}
def test_ensure_agy_feedback_survey_disabled_merges_preserving_keys(tmp_path: Path) -> None:
"""The user's other settings (model/trustedWorkspaces/…) survive the merge."""
settings = _agy_settings_path(tmp_path)
settings.parent.mkdir(parents=True)
settings.write_text(
json.dumps(
{
"enableTelemetry": False,
"model": "Gemini 3.5 Flash (High)",
"trustedWorkspaces": ["/work/a"],
}
),
encoding="utf-8",
)
ensure_agy_feedback_survey_disabled(tmp_path)
data = json.loads(settings.read_text(encoding="utf-8"))
assert data["showFeedbackSurvey"] is False
assert data["model"] == "Gemini 3.5 Flash (High)"
assert data["trustedWorkspaces"] == ["/work/a"]
assert data["enableTelemetry"] is False
def test_ensure_agy_feedback_survey_disabled_idempotent_no_rewrite(tmp_path: Path) -> None:
"""Already-false short-circuits before any (reformatting) rewrite."""
settings = _agy_settings_path(tmp_path)
settings.parent.mkdir(parents=True)
original = '{"showFeedbackSurvey": false, "model": "x"}' # compact + unsorted
settings.write_text(original, encoding="utf-8")
ensure_agy_feedback_survey_disabled(tmp_path)
# Untouched byte-for-byte: the function returned at the already-disabled guard
# rather than rewriting (which would indent + sort the keys).
assert settings.read_text(encoding="utf-8") == original
def test_ensure_agy_feedback_survey_disabled_skips_malformed(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""A malformed settings.json is left untouched (never clobbered)."""
settings = _agy_settings_path(tmp_path)
settings.parent.mkdir(parents=True)
settings.write_text("{ not valid json", encoding="utf-8")
with caplog.at_level(logging.WARNING):
ensure_agy_feedback_survey_disabled(tmp_path)
assert settings.read_text(encoding="utf-8") == "{ not valid json"
assert "not valid JSON" in caplog.text
def test_ensure_agy_feedback_survey_disabled_skips_non_dict(tmp_path: Path) -> None:
"""A JSON file that isn't an object is left untouched."""
settings = _agy_settings_path(tmp_path)
settings.parent.mkdir(parents=True)
settings.write_text('["a", "b"]', encoding="utf-8")
ensure_agy_feedback_survey_disabled(tmp_path)
assert settings.read_text(encoding="utf-8") == '["a", "b"]'
def test_ensure_agy_feedback_survey_disabled_leaves_non_utf8_untouched(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Non-UTF-8 bytes must not crash the launch nor clobber the file (#1494 review)."""
settings = _agy_settings_path(tmp_path)
settings.parent.mkdir(parents=True)
settings.write_bytes(b"\xff\xfe not valid utf-8 \x00")
with caplog.at_level(logging.WARNING):
ensure_agy_feedback_survey_disabled(tmp_path) # must NOT raise
assert settings.read_bytes() == b"\xff\xfe not valid utf-8 \x00"
assert "not valid UTF-8" in caplog.text
def test_ensure_agy_feedback_survey_disabled_skips_unreadable_existing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""An existing-but-unreadable file (OSError != FileNotFoundError) is not clobbered."""
settings = _agy_settings_path(tmp_path)
settings.parent.mkdir(parents=True)
settings.write_text('{"model": "x"}', encoding="utf-8")
def _boom(self: Path, *args: object, **kwargs: object) -> str:
raise PermissionError("unreadable")
monkeypatch.setattr(Path, "read_text", _boom)
with caplog.at_level(logging.WARNING):
ensure_agy_feedback_survey_disabled(tmp_path)
# Path.read_text is patched, so read back via the builtin open.
with open(settings, encoding="utf-8") as handle:
assert handle.read() == '{"model": "x"}'
assert "could not read" in caplog.text
def test_ensure_agy_feedback_survey_disabled_on_empty_file(tmp_path: Path) -> None:
"""An empty settings.json is treated as create-fresh."""
settings = _agy_settings_path(tmp_path)
settings.parent.mkdir(parents=True)
settings.write_text("", encoding="utf-8")
ensure_agy_feedback_survey_disabled(tmp_path)
assert json.loads(settings.read_text(encoding="utf-8")) == {"showFeedbackSurvey": False}
def test_ensure_agy_feedback_survey_disabled_flips_true_to_false(tmp_path: Path) -> None:
"""An explicit ``true`` is flipped to ``false`` (other keys preserved)."""
settings = _agy_settings_path(tmp_path)
settings.parent.mkdir(parents=True)
settings.write_text(json.dumps({"showFeedbackSurvey": True, "model": "x"}), encoding="utf-8")
ensure_agy_feedback_survey_disabled(tmp_path)
data = json.loads(settings.read_text(encoding="utf-8"))
assert data == {"showFeedbackSurvey": False, "model": "x"}
def test_ensure_agy_feedback_survey_disabled_follows_symlink(tmp_path: Path) -> None:
"""A symlinked settings.json (dotfiles) is followed, not replaced (agy review)."""
real = tmp_path / "dotfiles" / "agy-settings.json"
real.parent.mkdir(parents=True)
real.write_text(json.dumps({"model": "x"}), encoding="utf-8")
link = _agy_settings_path(tmp_path)
link.parent.mkdir(parents=True)
link.symlink_to(real)
ensure_agy_feedback_survey_disabled(tmp_path)
# The symlink is preserved (not clobbered into a regular file) and the real
# target gained the key.
assert link.is_symlink()
assert json.loads(real.read_text(encoding="utf-8")) == {
"model": "x",
"showFeedbackSurvey": False,
}
def test_ensure_agy_feedback_survey_disabled_write_failure_never_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
"""A write-side OSError is swallowed + logged so the launch is never broken (#1494 review).
The read of an existing-but-unreadable file is already covered; this exercises
the WRITE phase guarantee — a failing atomic replace must not propagate out of
a best-effort helper called inline on the launch path.
"""
settings = _agy_settings_path(tmp_path)
settings.parent.mkdir(parents=True)
settings.write_text(json.dumps({"model": "x"}), encoding="utf-8")
def _boom(*args: object, **kwargs: object) -> None:
raise OSError("disk full")
# os.replace is the last step of the atomic write; failing it stresses the
# finally-cleanup + outer best-effort guard together.
monkeypatch.setattr(_mod.os, "replace", _boom)
with caplog.at_level(logging.WARNING):
ensure_agy_feedback_survey_disabled(tmp_path) # must NOT raise
# The original file is untouched (the failed write never landed) and no stray
# temp file is left behind in the settings dir.
assert json.loads(settings.read_text(encoding="utf-8")) == {"model": "x"}
leftover = [p.name for p in settings.parent.iterdir() if p.name != settings.name]
assert leftover == []
assert "could not write" in caplog.text