0da23edd00
* 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>
2071 lines
85 KiB
Python
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
|