1249 lines
48 KiB
Python
1249 lines
48 KiB
Python
"""Tests for the native Antigravity (``omnigent antigravity``) launcher.
|
|
|
|
No live agy or server is started — the terminal-launch POST is driven through
|
|
an ``httpx.MockTransport`` so the request body shape is asserted without a real
|
|
runner.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
import omnigent.antigravity_native as _mod
|
|
import omnigent.antigravity_native_bridge as bridge_mod
|
|
from omnigent._wrapper_labels import ANTIGRAVITY_NATIVE_WRAPPER_VALUE, WRAPPER_LABEL_KEY
|
|
from omnigent.antigravity_native import antigravity_terminal_resource_id
|
|
from omnigent.antigravity_native_bridge import (
|
|
ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY,
|
|
read_bridge_state,
|
|
read_tmux_info,
|
|
)
|
|
|
|
|
|
def _mock_client(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.AsyncClient:
|
|
"""
|
|
Build an async client whose requests are served by ``handler``.
|
|
|
|
:param handler: ``httpx.MockTransport`` request handler.
|
|
:returns: An ``httpx.AsyncClient`` bound to a base URL and the handler.
|
|
"""
|
|
return httpx.AsyncClient(
|
|
base_url="http://127.0.0.1:0",
|
|
transport=httpx.MockTransport(handler),
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _stub_agy_binary(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""
|
|
Resolve the agy binary to a fixed name so launch tests need no real install.
|
|
|
|
``build_agy_launch`` uses ``agy_binary_path()`` as ``argv[0]`` unconditionally,
|
|
and that raises ``RuntimeError`` when agy is absent from ``PATH`` — which is the
|
|
case in CI. Patch the name at the site where ``build_agy_launch`` looks it up
|
|
(its own module), plus the re-export in :mod:`omnigent.antigravity_native` used
|
|
by the direct-CLI launch path, so no test depends on agy being installed.
|
|
|
|
This is autouse for the whole module; the real resolution / missing-agy
|
|
``RuntimeError`` path is covered separately in
|
|
``tests/test_antigravity_native_launch.py`` (which patches ``shutil.which``
|
|
directly), so nothing here needs the unstubbed binary lookup.
|
|
"""
|
|
monkeypatch.setattr("omnigent.antigravity_native_launch.agy_binary_path", lambda: "agy")
|
|
monkeypatch.setattr(_mod, "agy_binary_path", lambda: "agy")
|
|
|
|
|
|
async def test_launch_terminal_body_uses_ensure_native_terminal_not_bridge_inject() -> None:
|
|
"""
|
|
The terminal-launch POST opts in via ``ensure_native_terminal``, not ``bridge_inject_dir``.
|
|
|
|
``bridge_inject_dir`` is the Claude-native marker: on the runner it starts a
|
|
Claude comment relay, tags the terminal ``CLAUDE_NATIVE_TERMINAL_ROLE``, and
|
|
publishes Claude tmux metadata — side effects antigravity does not own and
|
|
must not trigger. The antigravity bootstrap must therefore use the
|
|
side-effect-free allowlist marker ``ensure_native_terminal``. This guards
|
|
against a regression that reintroduces the Claude relay on every agy launch.
|
|
"""
|
|
seen: dict[str, object] = {}
|
|
|
|
def _handler(request: httpx.Request) -> httpx.Response:
|
|
seen["body"] = json.loads(request.content)
|
|
return httpx.Response(200, json={"id": "terminal_antigravity_main", "metadata": {}})
|
|
|
|
async with _mock_client(_handler) as client:
|
|
await _mod._launch_antigravity_terminal(
|
|
client,
|
|
"conv_abc123",
|
|
argv=["agy", "--model", "gemini-2.5-pro"],
|
|
env={"FOO": "bar"},
|
|
command="agy",
|
|
)
|
|
|
|
body = seen["body"]
|
|
assert isinstance(body, dict)
|
|
assert body.get("ensure_native_terminal") is True
|
|
assert "bridge_inject_dir" not in body
|
|
assert body.get("terminal") == "antigravity"
|
|
assert body.get("session_key") == "main"
|
|
|
|
|
|
async def test_launch_terminal_passes_spec_args_without_binary() -> None:
|
|
"""
|
|
The launch spec carries the agy args (sans the binary) and the command separately.
|
|
|
|
Guards the argv split: ``argv[0]`` is the binary (sent as ``command``) and
|
|
the rest are the terminal ``spec.args`` — a mix-up would double the binary
|
|
or drop the first flag.
|
|
"""
|
|
seen: dict[str, object] = {}
|
|
|
|
def _handler(request: httpx.Request) -> httpx.Response:
|
|
seen["body"] = json.loads(request.content)
|
|
return httpx.Response(200, json={"id": "terminal_antigravity_main", "metadata": {}})
|
|
|
|
async with _mock_client(_handler) as client:
|
|
await _mod._launch_antigravity_terminal(
|
|
client,
|
|
"conv_abc123",
|
|
argv=["agy", "--conversation", "abc"],
|
|
env={},
|
|
command="agy",
|
|
)
|
|
|
|
body = seen["body"]
|
|
assert isinstance(body, dict)
|
|
spec = body.get("spec")
|
|
assert isinstance(spec, dict)
|
|
assert spec.get("command") == "agy"
|
|
assert spec.get("args") == ["--conversation", "abc"]
|
|
|
|
|
|
async def test_launch_terminal_raises_on_error_status() -> None:
|
|
"""
|
|
A non-2xx terminal-launch response raises a ClickException.
|
|
|
|
The launcher cannot proceed without a terminal, so a server error must
|
|
surface as a user-facing failure rather than a malformed success.
|
|
"""
|
|
import click
|
|
|
|
async with _mock_client(lambda request: httpx.Response(500, text="boom")) as client:
|
|
with pytest.raises(click.ClickException):
|
|
await _mod._launch_antigravity_terminal(
|
|
client,
|
|
"conv_abc123",
|
|
argv=["agy"],
|
|
env={},
|
|
command="agy",
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# daemon resume reattach (Fix 2)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _antigravity_session_payload() -> dict[str, object]:
|
|
"""
|
|
Build a minimal antigravity-native session GET payload.
|
|
|
|
:returns: A session payload carrying the wrapper + bridge-id labels and a
|
|
discovered ``external_session_id``.
|
|
"""
|
|
return {
|
|
"labels": {
|
|
WRAPPER_LABEL_KEY: ANTIGRAVITY_NATIVE_WRAPPER_VALUE,
|
|
ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY: "bridge_xyz",
|
|
},
|
|
"external_session_id": "68caaeac-2eaf-4e2c-9b95-721b022f4903",
|
|
}
|
|
|
|
|
|
def _patch_prepare_client(monkeypatch: pytest.MonkeyPatch, client: httpx.AsyncClient) -> None:
|
|
"""
|
|
Make ``_prepare_antigravity_terminal_via_daemon`` use ``client``.
|
|
|
|
The prepare fn opens its own ``async with httpx.AsyncClient(...)``; this
|
|
swaps the constructor for a proxy that yields the test's mock-transport
|
|
client so the GETs are served by the test handler.
|
|
|
|
:param monkeypatch: pytest monkeypatch fixture.
|
|
:param client: Mock-transport client to serve the prepare fn's requests.
|
|
:returns: None.
|
|
"""
|
|
|
|
class _ProxyClient:
|
|
def __init__(self, *args: object, **kwargs: object) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self) -> httpx.AsyncClient:
|
|
return client
|
|
|
|
async def __aexit__(self, *exc: object) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(_mod.httpx, "AsyncClient", _ProxyClient)
|
|
|
|
|
|
async def test_daemon_resume_reattaches_to_running_terminal(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""
|
|
A daemon resume with a live agy terminal reattaches instead of relaunching.
|
|
|
|
This is the Fix-2 invariant: the daemon resume path must check for a
|
|
running runner-owned terminal *before* binding/launching, and on a hit
|
|
return ``reattached=True`` without ever calling ``_launch_and_record``
|
|
(whose unconditional ``clear_bridge_state`` would wipe the live forwarder's
|
|
discovered ``conversation_id``) or closing a terminal another launcher owns.
|
|
|
|
:param monkeypatch: pytest monkeypatch fixture.
|
|
:param tmp_path: pytest temp dir, used to isolate the bridge root.
|
|
:returns: None.
|
|
"""
|
|
import omnigent.antigravity_native_bridge as bridge_mod
|
|
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
terminal_id = antigravity_terminal_resource_id()
|
|
|
|
def _handler(request: httpx.Request) -> httpx.Response:
|
|
path = request.url.path
|
|
if request.method == "GET" and path.endswith(f"/resources/terminals/{terminal_id}"):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": terminal_id,
|
|
"metadata": {
|
|
"running": True,
|
|
"tmux_socket": "/tmp/s.sock",
|
|
"tmux_target": "main",
|
|
},
|
|
},
|
|
)
|
|
if request.method == "GET":
|
|
return httpx.Response(200, json=_antigravity_session_payload())
|
|
raise AssertionError(f"unexpected request: {request.method} {path}")
|
|
|
|
async def _boom_launch(*args: object, **kwargs: object) -> object:
|
|
raise AssertionError("daemon resume must not relaunch when a terminal is live")
|
|
|
|
async def _boom_host_online(*args: object, **kwargs: object) -> None:
|
|
raise AssertionError("daemon resume must reattach before waiting on the host")
|
|
|
|
monkeypatch.setattr(_mod, "_launch_and_record", _boom_launch)
|
|
monkeypatch.setattr(_mod, "wait_for_host_online", _boom_host_online)
|
|
|
|
async with _mock_client(_handler) as client:
|
|
_patch_prepare_client(monkeypatch, client)
|
|
prepared = await _mod._prepare_antigravity_terminal_via_daemon(
|
|
base_url="http://127.0.0.1:0",
|
|
headers={"Authorization": "Bearer t"},
|
|
session_id="conv_abc123",
|
|
session_bundle=None,
|
|
antigravity_args=(),
|
|
command="agy",
|
|
model=None,
|
|
host_id="host_1",
|
|
workspace="/tmp/ws",
|
|
startup_progress=None,
|
|
)
|
|
|
|
assert prepared.reattached is True
|
|
assert prepared.terminal_id == terminal_id
|
|
assert prepared.tmux_target == "main"
|
|
|
|
|
|
async def test_daemon_resume_cold_falls_through_to_launch(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""
|
|
A daemon resume with no live terminal proceeds to bind + launch.
|
|
|
|
The reattach check must not swallow a cold resume: when the terminal GET
|
|
reports no running terminal (404), the path falls through to the normal
|
|
host-online/bind/launch sequence and returns ``reattached=False``.
|
|
|
|
:param monkeypatch: pytest monkeypatch fixture.
|
|
:param tmp_path: pytest temp dir, used to isolate the bridge root.
|
|
:returns: None.
|
|
"""
|
|
import omnigent.antigravity_native_bridge as bridge_mod
|
|
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
terminal_id = antigravity_terminal_resource_id()
|
|
|
|
def _handler(request: httpx.Request) -> httpx.Response:
|
|
path = request.url.path
|
|
if request.method == "GET" and path.endswith(f"/resources/terminals/{terminal_id}"):
|
|
return httpx.Response(404, json={"error": {"code": "not_found"}})
|
|
if request.method == "GET":
|
|
return httpx.Response(200, json=_antigravity_session_payload())
|
|
raise AssertionError(f"unexpected request: {request.method} {path}")
|
|
|
|
calls: dict[str, object] = {}
|
|
|
|
async def _record_launch(client: object, **kwargs: object) -> _mod.LaunchedAntigravityTerminal:
|
|
calls["launch_kwargs"] = kwargs
|
|
return _mod.LaunchedAntigravityTerminal(
|
|
terminal_id=terminal_id, tmux_socket=None, tmux_target=None
|
|
)
|
|
|
|
async def _noop(*args: object, **kwargs: object) -> object:
|
|
return None
|
|
|
|
monkeypatch.setattr(_mod, "_launch_and_record", _record_launch)
|
|
monkeypatch.setattr(_mod, "wait_for_host_online", _noop)
|
|
monkeypatch.setattr(_mod, "wait_for_runner_online", _noop)
|
|
monkeypatch.setattr(_mod, "launch_or_reuse_daemon_runner", _noop)
|
|
monkeypatch.setattr(_mod, "_bind_session_runner", _noop)
|
|
# The runner produces no terminal in this scenario (handler always 404s), so
|
|
# shorten the post-bind wait to keep the fallback-to-launch test fast.
|
|
monkeypatch.setattr(_mod, "_RUNNER_TERMINAL_AUTOCREATE_TIMEOUT_S", 0.05)
|
|
monkeypatch.setattr(_mod, "_RUNNER_TERMINAL_POLL_INTERVAL_S", 0.01)
|
|
|
|
async with _mock_client(_handler) as client:
|
|
_patch_prepare_client(monkeypatch, client)
|
|
prepared = await _mod._prepare_antigravity_terminal_via_daemon(
|
|
base_url="http://127.0.0.1:0",
|
|
headers={},
|
|
session_id="conv_abc123",
|
|
session_bundle=None,
|
|
antigravity_args=(),
|
|
command="agy",
|
|
model=None,
|
|
host_id="host_1",
|
|
workspace="/tmp/ws",
|
|
startup_progress=None,
|
|
)
|
|
|
|
assert prepared.reattached is False
|
|
assert "launch_kwargs" in calls
|
|
# On resume the launch must target agy's real (discovered) conversation id.
|
|
launch_kwargs = calls["launch_kwargs"]
|
|
assert isinstance(launch_kwargs, dict)
|
|
assert launch_kwargs["resume"] is True
|
|
assert launch_kwargs["conversation_id"] == "68caaeac-2eaf-4e2c-9b95-721b022f4903"
|
|
|
|
|
|
async def test_daemon_fresh_launch_reattaches_to_runner_autocreated_terminal(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""
|
|
A fresh launch reattaches to the runner's auto-created terminal, not relaunch.
|
|
|
|
Regression for the double-launch bug: binding the runner triggers the runner's
|
|
auto-create of the antigravity terminal (``runner/app.py``
|
|
``_auto_create_antigravity_terminal``). The CLI must then reattach to that
|
|
runner-owned terminal — NOT call ``_launch_and_record`` (whose
|
|
``clear_bridge_state`` wipes the bridge state the runner just wrote, breaking
|
|
web-turn injection with "Antigravity native bridge state is missing", while its
|
|
redundant terminal POST 500s). The pre-bind reattach check (resume path) cannot
|
|
catch this because the runner only auto-creates AFTER the bind.
|
|
"""
|
|
import omnigent.antigravity_native_bridge as bridge_mod
|
|
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
terminal_id = antigravity_terminal_resource_id()
|
|
|
|
def _handler(request: httpx.Request) -> httpx.Response:
|
|
path = request.url.path
|
|
# The runner has auto-created the terminal by the time we poll (post-bind).
|
|
if request.method == "GET" and path.endswith(f"/resources/terminals/{terminal_id}"):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": terminal_id,
|
|
"metadata": {
|
|
"running": True,
|
|
"tmux_socket": "/tmp/s.sock",
|
|
"tmux_target": "main",
|
|
},
|
|
},
|
|
)
|
|
raise AssertionError(f"unexpected request: {request.method} {path}")
|
|
|
|
async def _create(*args: object, **kwargs: object) -> str:
|
|
return "conv_fresh123"
|
|
|
|
async def _boom_launch(*args: object, **kwargs: object) -> object:
|
|
raise AssertionError(
|
|
"fresh launch must reattach to the runner-owned terminal, not relaunch "
|
|
"(double-launch clobbers the runner's bridge state)"
|
|
)
|
|
|
|
async def _noop(*args: object, **kwargs: object) -> object:
|
|
return None
|
|
|
|
monkeypatch.setattr(_mod, "_create_antigravity_session", _create)
|
|
monkeypatch.setattr(_mod, "_launch_and_record", _boom_launch)
|
|
monkeypatch.setattr(_mod, "wait_for_host_online", _noop)
|
|
monkeypatch.setattr(_mod, "wait_for_runner_online", _noop)
|
|
monkeypatch.setattr(_mod, "launch_or_reuse_daemon_runner", _noop)
|
|
monkeypatch.setattr(_mod, "_bind_session_runner", _noop)
|
|
|
|
async with _mock_client(_handler) as client:
|
|
_patch_prepare_client(monkeypatch, client)
|
|
prepared = await _mod._prepare_antigravity_terminal_via_daemon(
|
|
base_url="http://127.0.0.1:0",
|
|
headers={},
|
|
session_id=None,
|
|
session_bundle=b"bundle",
|
|
antigravity_args=(),
|
|
command="agy",
|
|
model=None,
|
|
host_id="host_1",
|
|
workspace="/tmp/ws",
|
|
startup_progress=None,
|
|
)
|
|
|
|
assert prepared.reattached is True, "fresh launch must reattach to the runner-owned terminal"
|
|
assert prepared.terminal_id == terminal_id
|
|
assert prepared.tmux_target == "main"
|
|
|
|
|
|
async def test_local_fresh_launch_reattaches_to_runner_autocreated_terminal(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""
|
|
The LOCAL (non-daemon) prepare path also reattaches to the runner-owned terminal.
|
|
|
|
Regression for the double-launch/double-forward gap on the default
|
|
``omnigent antigravity`` path: the local server spawns a CLI runner, and
|
|
binding it triggers the runner's ``_auto_create_antigravity_terminal`` exactly
|
|
as the daemon path does. ``_prepare_antigravity_terminal`` must reattach to that
|
|
runner-owned terminal after the bind — NOT call ``_launch_and_record`` (whose
|
|
``clear_bridge_state`` wipes the runner's bridge state and whose redundant
|
|
terminal POST 500s, and which on ``reattached=False`` also starts a second CLI
|
|
RPC reader → double-mirror). The original fix (7df3ba4d/f4ce3ce8) only patched
|
|
``_prepare_antigravity_terminal_via_daemon``; this guards the local-server
|
|
path.
|
|
"""
|
|
import omnigent.antigravity_native_bridge as bridge_mod
|
|
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
terminal_id = antigravity_terminal_resource_id()
|
|
|
|
def _handler(request: httpx.Request) -> httpx.Response:
|
|
path = request.url.path
|
|
# The runner has auto-created the terminal by the time we poll (post-bind).
|
|
if request.method == "GET" and path.endswith(f"/resources/terminals/{terminal_id}"):
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": terminal_id,
|
|
"metadata": {
|
|
"running": True,
|
|
"tmux_socket": "/tmp/s.sock",
|
|
"tmux_target": "main",
|
|
},
|
|
},
|
|
)
|
|
raise AssertionError(f"unexpected request: {request.method} {path}")
|
|
|
|
async def _create(*args: object, **kwargs: object) -> str:
|
|
return "conv_local_fresh"
|
|
|
|
async def _boom_launch(*args: object, **kwargs: object) -> object:
|
|
raise AssertionError(
|
|
"local fresh launch must reattach to the runner-owned terminal, not relaunch "
|
|
"(double-launch clobbers the runner's bridge state and double-mirrors)"
|
|
)
|
|
|
|
async def _noop(*args: object, **kwargs: object) -> object:
|
|
return None
|
|
|
|
monkeypatch.setattr(_mod, "_create_antigravity_session", _create)
|
|
monkeypatch.setattr(_mod, "_launch_and_record", _boom_launch)
|
|
monkeypatch.setattr(_mod, "_bind_session_runner", _noop)
|
|
|
|
async with _mock_client(_handler) as client:
|
|
_patch_prepare_client(monkeypatch, client)
|
|
prepared = await _mod._prepare_antigravity_terminal(
|
|
base_url="http://127.0.0.1:0",
|
|
headers={},
|
|
session_id=None,
|
|
runner_id="runner_local_1",
|
|
session_bundle=b"bundle",
|
|
antigravity_args=(),
|
|
command="agy",
|
|
model=None,
|
|
startup_progress=None,
|
|
)
|
|
|
|
assert prepared.reattached is True, (
|
|
"local fresh launch must reattach to the runner-owned terminal"
|
|
)
|
|
assert prepared.terminal_id == terminal_id
|
|
assert prepared.tmux_target == "main"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _launch_and_record: terminal launch + bridge-state seeding
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_PLACEHOLDER_ID = "agy_conv_fresh_placeholder"
|
|
|
|
|
|
def _terminal_launch_handler(request: httpx.Request) -> httpx.Response:
|
|
"""Minimal handler that accepts the terminal POST and returns a stub resource."""
|
|
return httpx.Response(200, json={"id": "terminal_antigravity_main", "metadata": {}})
|
|
|
|
|
|
def _terminal_launch_handler_with_pane(request: httpx.Request) -> httpx.Response:
|
|
"""Terminal POST handler that exposes a local tmux pane in the response metadata."""
|
|
return httpx.Response(
|
|
200,
|
|
json={
|
|
"id": "terminal_antigravity_main",
|
|
"metadata": {"tmux_socket": "/tmp/agy/tmux.sock", "tmux_target": "main"},
|
|
},
|
|
)
|
|
|
|
|
|
async def test_launch_and_record_advertises_tmux_target_when_pane_exposed(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""
|
|
When the runner exposes a local tmux pane, ``_launch_and_record`` advertises it.
|
|
|
|
A CLI-launched session's web turns are typed into the agy TUI by the executor,
|
|
which reads the pane from ``tmux.json`` — so ``_launch_and_record`` must write
|
|
it whenever the launched terminal carries a socket + target. (No pane ⇒ no
|
|
write; that path is covered by the other launch tests, whose handler returns
|
|
empty metadata.)
|
|
"""
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
bridge_id = "bridge_tmux_advertise"
|
|
async with _mock_client(_terminal_launch_handler_with_pane) as client:
|
|
await _mod._launch_and_record(
|
|
client,
|
|
session_id="conv_tmux",
|
|
bridge_id=bridge_id,
|
|
conversation_id="agy_conv_placeholder",
|
|
resume=False,
|
|
antigravity_args=(),
|
|
command="agy",
|
|
model=None,
|
|
startup_progress=None,
|
|
)
|
|
info = read_tmux_info(bridge_mod.bridge_dir_for_bridge_id(bridge_id))
|
|
assert info == {"socket_path": "/tmp/agy/tmux.sock", "tmux_target": "main"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _launch_is_headless — attended vs unattended signal (phase 4 task 1)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeStream:
|
|
"""Minimal stdin/stdout stand-in with a controllable ``isatty``."""
|
|
|
|
def __init__(self, *, tty: bool, raises: bool = False) -> None:
|
|
self._tty = tty
|
|
self._raises = raises
|
|
|
|
def isatty(self) -> bool:
|
|
if self._raises:
|
|
raise ValueError("I/O operation on closed file")
|
|
return self._tty
|
|
|
|
|
|
def test_launch_is_headless_false_when_both_tty(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A controlling TTY on stdin AND stdout means an interactive client attaches."""
|
|
monkeypatch.setattr(sys, "stdin", _FakeStream(tty=True))
|
|
monkeypatch.setattr(sys, "stdout", _FakeStream(tty=True))
|
|
assert _mod._launch_is_headless() is False
|
|
|
|
|
|
def test_launch_is_headless_true_when_stdin_not_tty(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A non-TTY stdin (pipe / CI / detached) is headless."""
|
|
monkeypatch.setattr(sys, "stdin", _FakeStream(tty=False))
|
|
monkeypatch.setattr(sys, "stdout", _FakeStream(tty=True))
|
|
assert _mod._launch_is_headless() is True
|
|
|
|
|
|
def test_launch_is_headless_true_when_stdout_not_tty(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A non-TTY stdout (piped output) is headless."""
|
|
monkeypatch.setattr(sys, "stdin", _FakeStream(tty=True))
|
|
monkeypatch.setattr(sys, "stdout", _FakeStream(tty=False))
|
|
assert _mod._launch_is_headless() is True
|
|
|
|
|
|
def test_launch_is_headless_true_on_closed_stream(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A closed/detached stream raising ValueError is treated as headless (safe)."""
|
|
monkeypatch.setattr(sys, "stdin", _FakeStream(tty=True, raises=True))
|
|
monkeypatch.setattr(sys, "stdout", _FakeStream(tty=True))
|
|
assert _mod._launch_is_headless() is True
|
|
|
|
|
|
async def test_launch_and_record_threads_headless_skip_flag(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""``headless=True`` adds ``--dangerously-skip-permissions`` to the POSTed argv.
|
|
|
|
Confirms the phase-4 task-1 wiring is threaded all the way through
|
|
``_launch_and_record`` → ``build_agy_launch`` → the terminal-launch
|
|
``spec.args``, not just the unit-level ``should_skip_permissions``.
|
|
"""
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
|
|
captured_args: list[str] = []
|
|
|
|
def _capture_handler(request: httpx.Request) -> httpx.Response:
|
|
body = json.loads(request.content)
|
|
captured_args.extend(body["spec"]["args"])
|
|
return httpx.Response(200, json={"id": "terminal_antigravity_main", "metadata": {}})
|
|
|
|
async with _mock_client(_capture_handler) as client:
|
|
await _mod._launch_and_record(
|
|
client,
|
|
session_id="conv_abc123",
|
|
bridge_id="bridge_headless_test",
|
|
conversation_id=_PLACEHOLDER_ID,
|
|
resume=False,
|
|
antigravity_args=(),
|
|
command="agy",
|
|
model=None,
|
|
permission_mode=None,
|
|
headless=True,
|
|
startup_progress=None,
|
|
)
|
|
|
|
assert "--dangerously-skip-permissions" in captured_args
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _attach_terminal teardown: the eager terminal-close decision
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _prepared(reattached: bool) -> _mod.PreparedAntigravityTerminal:
|
|
"""
|
|
Build a PreparedAntigravityTerminal for the attach-teardown tests.
|
|
|
|
A non-None tmux socket/target makes the direct-attach branch eligible (the
|
|
tests stub the attach itself), isolating the ``finally`` close decision.
|
|
|
|
:param reattached: Whether this invocation reused an existing terminal.
|
|
:returns: A prepared terminal with placeholder ids and tmux metadata.
|
|
"""
|
|
return _mod.PreparedAntigravityTerminal(
|
|
session_id="conv_close",
|
|
terminal_id="term_close",
|
|
bridge_dir=Path("/tmp/agy-close-test"),
|
|
tmux_socket=Path("/tmp/agy-close-test.sock"),
|
|
tmux_target="main",
|
|
reattached=reattached,
|
|
)
|
|
|
|
|
|
async def _run_attach_and_capture_close(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
*,
|
|
reattached: bool,
|
|
outcome: _mod._AttachOutcome,
|
|
) -> list[str]:
|
|
"""
|
|
Drive ``_attach_terminal`` with stubbed attach + reader + cold-start + close,
|
|
returning the terminal ids passed to ``_close_antigravity_terminal`` (empty =
|
|
not closed).
|
|
|
|
The reader is stubbed to run until cancelled (so the ``finally`` teardown
|
|
exercises its cancel path); the cold-start is a no-op; the direct tmux attach
|
|
is stubbed to return the chosen *outcome* without touching tmux.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param reattached: ``prepared.reattached`` for this run.
|
|
:param outcome: The ``_AttachOutcome`` the (stubbed) attach returns.
|
|
:returns: Terminal ids ``_close_antigravity_terminal`` was called with.
|
|
"""
|
|
closed_terminal_ids: list[str] = []
|
|
|
|
async def _fake_reader(**_kwargs: object) -> None:
|
|
# Run until the finally-block cancels the task, like the real reader.
|
|
await asyncio.Event().wait()
|
|
|
|
async def _fake_cold_start(*_args: object, **_kwargs: object) -> None:
|
|
return None
|
|
|
|
async def _fake_direct_attach(_socket: Path, _target: str) -> _mod._AttachOutcome:
|
|
return outcome
|
|
|
|
async def _fake_close(**kwargs: object) -> None:
|
|
closed_terminal_ids.append(str(kwargs["terminal_id"]))
|
|
|
|
monkeypatch.setattr(_mod, "run_reader_with_bridge", _fake_reader)
|
|
monkeypatch.setattr(_mod, "_cold_start_agy_conversation", _fake_cold_start)
|
|
monkeypatch.setattr(_mod, "_can_attach_direct_tmux", lambda _prepared: True)
|
|
monkeypatch.setattr(_mod, "_attach_direct_tmux", _fake_direct_attach)
|
|
monkeypatch.setattr(_mod, "_close_antigravity_terminal", _fake_close)
|
|
|
|
await _mod._attach_terminal(
|
|
base_url="http://test",
|
|
headers={},
|
|
prepared=_prepared(reattached),
|
|
recover=None,
|
|
)
|
|
return closed_terminal_ids
|
|
|
|
|
|
async def test_attach_terminal_closes_on_real_exit_when_owned(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A real exit (EXITED) of a terminal THIS invocation owns closes it.
|
|
|
|
The owning launcher is responsible for teardown; not closing here would leak
|
|
the agy terminal resource on a normal quit.
|
|
"""
|
|
closed = await _run_attach_and_capture_close(
|
|
monkeypatch, reattached=False, outcome=_mod._AttachOutcome.EXITED
|
|
)
|
|
assert closed == ["term_close"]
|
|
|
|
|
|
async def test_attach_terminal_skips_close_on_detach(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A tmux DETACH must NOT close the terminal — agy keeps running for re-attach.
|
|
|
|
Detaching is "leave it running"; closing here would kill a live agy session
|
|
the user intends to come back to.
|
|
"""
|
|
closed = await _run_attach_and_capture_close(
|
|
monkeypatch, reattached=False, outcome=_mod._AttachOutcome.DETACHED
|
|
)
|
|
assert closed == []
|
|
|
|
|
|
async def test_attach_terminal_skips_close_when_reattached_even_on_exit(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A reattached invocation must NOT close on exit — it does not own teardown.
|
|
|
|
This invocation reused a terminal another launcher created; closing it on our
|
|
exit would tear down a resource we do not own (the over-close seam).
|
|
"""
|
|
closed = await _run_attach_and_capture_close(
|
|
monkeypatch, reattached=True, outcome=_mod._AttachOutcome.EXITED
|
|
)
|
|
assert closed == []
|
|
|
|
|
|
async def test_attach_terminal_skips_close_when_reattached_and_detached(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Reattached AND detached: both reasons to skip close — and it is skipped."""
|
|
closed = await _run_attach_and_capture_close(
|
|
monkeypatch, reattached=True, outcome=_mod._AttachOutcome.DETACHED
|
|
)
|
|
assert closed == []
|
|
|
|
|
|
async def _run_attach_and_capture_reader(
|
|
monkeypatch: pytest.MonkeyPatch, *, reattached: bool
|
|
) -> tuple[int, int]:
|
|
"""
|
|
Drive ``_attach_terminal`` and count the CLI reader + cold-start spawns.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param reattached: ``prepared.reattached`` for this run.
|
|
:returns: ``(reader_starts, cold_start_starts)`` — each 0 or 1.
|
|
"""
|
|
reader_starts = 0
|
|
cold_start_starts = 0
|
|
|
|
def _counting_reader(**_kwargs: object) -> object:
|
|
# Count the CALL synchronously (deterministic) — not the task body, which
|
|
# may be cancelled by the finally before it runs. Returns a coroutine so
|
|
# ``asyncio.create_task(run_reader_with_bridge(...))`` still works.
|
|
nonlocal reader_starts
|
|
reader_starts += 1
|
|
|
|
async def _runner() -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
return _runner()
|
|
|
|
def _counting_cold_start(*_args: object, **_kwargs: object) -> object:
|
|
nonlocal cold_start_starts
|
|
cold_start_starts += 1
|
|
|
|
async def _runner() -> None:
|
|
return None
|
|
|
|
return _runner()
|
|
|
|
async def _fake_direct_attach(_socket: Path, _target: str) -> _mod._AttachOutcome:
|
|
return _mod._AttachOutcome.EXITED
|
|
|
|
async def _fake_close(**_kwargs: object) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(_mod, "run_reader_with_bridge", _counting_reader)
|
|
monkeypatch.setattr(_mod, "_cold_start_agy_conversation", _counting_cold_start)
|
|
monkeypatch.setattr(_mod, "_can_attach_direct_tmux", lambda _prepared: True)
|
|
monkeypatch.setattr(_mod, "_attach_direct_tmux", _fake_direct_attach)
|
|
monkeypatch.setattr(_mod, "_close_antigravity_terminal", _fake_close)
|
|
|
|
await _mod._attach_terminal(
|
|
base_url="http://test",
|
|
headers={},
|
|
prepared=_prepared(reattached),
|
|
recover=None,
|
|
)
|
|
return reader_starts, cold_start_starts
|
|
|
|
|
|
async def test_attach_terminal_skips_cli_reader_when_reattached(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Reattaching to a runner-owned terminal must NOT start a CLI-side reader.
|
|
|
|
The runner auto-creates "terminal + reader" together and owns the mirror for a
|
|
runner-owned terminal. A second CLI-side reader would double-mirror every step
|
|
(two readers POSTing the same agy conversation). So when ``reattached`` the CLI
|
|
defers to the runner — and skips cold-start too (the runner cold-starts).
|
|
"""
|
|
reader_starts, cold_start_starts = await _run_attach_and_capture_reader(
|
|
monkeypatch, reattached=True
|
|
)
|
|
assert reader_starts == 0, "reattached must defer mirroring to the runner (no double-mirror)"
|
|
assert cold_start_starts == 0, "reattached must not cold-start (runner owns it)"
|
|
|
|
|
|
async def test_attach_terminal_runs_cli_reader_when_not_reattached(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""When the CLI launched its own terminal (fallback), it DOES run the reader.
|
|
|
|
No runner-owned terminal exists in that case, so the CLI is the only mirror
|
|
source: it spawns the RPC reader AND a one-shot cold-start (which mints agy's
|
|
real cascade id so the reader binds it).
|
|
"""
|
|
reader_starts, cold_start_starts = await _run_attach_and_capture_reader(
|
|
monkeypatch, reattached=False
|
|
)
|
|
assert reader_starts == 1, "non-reattached (CLI-launched) path must run the RPC reader"
|
|
assert cold_start_starts == 1, "non-reattached path must cold-start agy's conversation"
|
|
|
|
|
|
async def _capture_cold_start_pane_kwargs(
|
|
monkeypatch: pytest.MonkeyPatch, *, socket_exists: bool, tmp_path: Path
|
|
) -> dict[str, object]:
|
|
"""
|
|
Drive ``_attach_terminal`` (non-reattached) and capture the cold-start kwargs.
|
|
|
|
:param monkeypatch: Pytest monkeypatch fixture.
|
|
:param socket_exists: Whether ``prepared.tmux_socket`` exists on this host.
|
|
:param tmp_path: Temp dir to host the (optionally real) socket file.
|
|
:returns: The kwargs ``_cold_start_agy_conversation`` was called with.
|
|
"""
|
|
sock = tmp_path / "agy.sock"
|
|
if socket_exists:
|
|
sock.write_bytes(b"") # a real local file → pane is local
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def _capturing_cold_start(*_args: object, **kwargs: object) -> object:
|
|
captured.update(kwargs)
|
|
|
|
async def _runner() -> None:
|
|
return None
|
|
|
|
return _runner()
|
|
|
|
def _counting_reader(**_kwargs: object) -> object:
|
|
async def _runner() -> None:
|
|
await asyncio.Event().wait()
|
|
|
|
return _runner()
|
|
|
|
async def _fake_direct_attach(_socket: Path, _target: str) -> _mod._AttachOutcome:
|
|
return _mod._AttachOutcome.EXITED
|
|
|
|
async def _fake_close(**_kwargs: object) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(_mod, "run_reader_with_bridge", _counting_reader)
|
|
monkeypatch.setattr(_mod, "_cold_start_agy_conversation", _capturing_cold_start)
|
|
monkeypatch.setattr(_mod, "_can_attach_direct_tmux", lambda _prepared: True)
|
|
monkeypatch.setattr(_mod, "_attach_direct_tmux", _fake_direct_attach)
|
|
monkeypatch.setattr(_mod, "_close_antigravity_terminal", _fake_close)
|
|
|
|
prepared = _mod.PreparedAntigravityTerminal(
|
|
session_id="conv_pane",
|
|
terminal_id="term_pane",
|
|
bridge_dir=tmp_path / "bridge",
|
|
tmux_socket=sock,
|
|
tmux_target="main",
|
|
reattached=False,
|
|
)
|
|
await _mod._attach_terminal(
|
|
base_url="http://test", headers={}, prepared=prepared, recover=None
|
|
)
|
|
return captured
|
|
|
|
|
|
async def test_attach_threads_local_pane_into_cold_start(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A locally-existing tmux socket is threaded into the cold-start for scoping."""
|
|
captured = await _capture_cold_start_pane_kwargs(
|
|
monkeypatch, socket_exists=True, tmp_path=tmp_path
|
|
)
|
|
assert captured["tmux_socket"] == tmp_path / "agy.sock"
|
|
assert captured["tmux_target"] == "main"
|
|
|
|
|
|
async def test_attach_omits_remote_pane_from_cold_start(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""A non-local socket (remote runner) is NOT threaded in — avoids doomed tmux spawns.
|
|
|
|
MINOR-1 guard: the runner advertises a server-side socket PATH that does not
|
|
exist locally. Passing it would make the cold-start run a failing
|
|
``tmux -S <remote-path> display-message`` on every poll. Gating on local
|
|
existence (mirroring ``_can_attach_direct_tmux``) routes the remote case to the
|
|
no-pane -> candidate fallback instead.
|
|
"""
|
|
captured = await _capture_cold_start_pane_kwargs(
|
|
monkeypatch, socket_exists=False, tmp_path=tmp_path
|
|
)
|
|
assert captured["tmux_socket"] is None
|
|
assert captured["tmux_target"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _cold_start_agy_conversation — mint + persist + external_session_id PATCH
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _seed_bridge_state(bridge_dir: Path, conversation_id: str) -> None:
|
|
"""Seed bridge state with a given conversation id for cold-start tests."""
|
|
from omnigent.antigravity_native_bridge import (
|
|
AntigravityNativeBridgeState,
|
|
write_bridge_state,
|
|
)
|
|
|
|
write_bridge_state(
|
|
bridge_dir,
|
|
AntigravityNativeBridgeState(session_id="conv_cs", conversation_id=conversation_id),
|
|
)
|
|
|
|
|
|
async def test_cli_cold_start_mints_without_patching_external_session_id(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""
|
|
A placeholder-seeded CLI cold-start mints a real id + writes it, but does NOT
|
|
record it as external_session_id.
|
|
|
|
The cold-start cascade is the headless ``StartCascade`` bootstrap the agy TUI
|
|
never displays; recording it as ``external_session_id`` lost the whole
|
|
conversation on resume (a ``--resume`` loaded the empty phantom). The TUI
|
|
mints its OWN cascade on the first typed turn, which the read driver adopts
|
|
and records instead. So the cold-start ``StartCascade``s a real uuid4 and
|
|
persists it to bridge state (for the reader to bind), but issues NO PATCH.
|
|
"""
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
bridge_dir = bridge_mod.bridge_dir_for_bridge_id("bridge_cs")
|
|
_seed_bridge_state(bridge_dir, "agy_conv_placeholder")
|
|
|
|
monkeypatch.setattr(_mod, "resolve_cold_start_agy_rpc_port", lambda _sock, _tgt: 52548)
|
|
started: list[tuple[int, str]] = []
|
|
monkeypatch.setattr(_mod, "start_cascade", lambda port, cid: started.append((port, cid)))
|
|
|
|
patches: list[tuple[str, dict[str, object]]] = []
|
|
|
|
def _handler(request: httpx.Request) -> httpx.Response:
|
|
patches.append((request.url.path, json.loads(request.content)))
|
|
return httpx.Response(200, json={})
|
|
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def _mock_async_client(**kwargs: object) -> httpx.AsyncClient:
|
|
kwargs.pop("transport", None)
|
|
return real_async_client(transport=httpx.MockTransport(_handler), **kwargs)
|
|
|
|
monkeypatch.setattr(_mod.httpx, "AsyncClient", _mock_async_client)
|
|
|
|
await _mod._cold_start_agy_conversation(
|
|
bridge_dir,
|
|
"conv_cs",
|
|
base_url="http://test",
|
|
headers={},
|
|
timeout_s=1.0,
|
|
)
|
|
|
|
# Minted exactly one cascade on the discovered port with a real (non-placeholder) id.
|
|
assert len(started) == 1
|
|
minted_port, minted_id = started[0]
|
|
assert minted_port == 52548
|
|
assert not _mod.is_placeholder_conversation_id(minted_id)
|
|
# The real id reached bridge state (placeholder replaced).
|
|
after = read_bridge_state(bridge_dir)
|
|
assert after is not None
|
|
assert after.conversation_id == minted_id
|
|
# NO external_session_id PATCH: the cold-start no longer records the headless
|
|
# phantom (the reader records the adopted TUI cascade instead) — #2 data-loss.
|
|
assert patches == []
|
|
|
|
|
|
async def test_cli_cold_start_skips_when_conversation_already_real(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""
|
|
A resume (non-placeholder id already seeded) skips cold-start AND the PATCH.
|
|
|
|
This is the guard that makes ``--resume`` work: the real prior id is already
|
|
seeded (and passed as ``--conversation``), so the cold-start must not mint a
|
|
fresh conversation (which would clobber the resumed id) and must not PATCH.
|
|
"""
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
bridge_dir = bridge_mod.bridge_dir_for_bridge_id("bridge_cs_resume")
|
|
real_id = "68caaeac-2eaf-4e2c-9b95-721b022f4903"
|
|
_seed_bridge_state(bridge_dir, real_id)
|
|
|
|
def _fail_ports(_sock: object, _tgt: object) -> int | None:
|
|
raise AssertionError("cold-start must not probe ports on a non-placeholder (resume) id")
|
|
|
|
monkeypatch.setattr(_mod, "resolve_cold_start_agy_rpc_port", _fail_ports)
|
|
|
|
def _fail_client(**_kwargs: object) -> httpx.AsyncClient:
|
|
raise AssertionError("cold-start must not PATCH on a resume id")
|
|
|
|
monkeypatch.setattr(_mod.httpx, "AsyncClient", _fail_client)
|
|
|
|
await _mod._cold_start_agy_conversation(
|
|
bridge_dir,
|
|
"conv_cs",
|
|
base_url="http://test",
|
|
headers={},
|
|
timeout_s=1.0,
|
|
)
|
|
|
|
# Bridge state untouched — the resume id stands.
|
|
after = read_bridge_state(bridge_dir)
|
|
assert after is not None
|
|
assert after.conversation_id == real_id
|
|
|
|
|
|
async def test_cli_cold_start_scopes_to_pane_agy(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""
|
|
With several agy candidates, the CLI cold-start binds THIS session's pane agy.
|
|
|
|
Mirrors the runner cross-bind fix on the CLI fallback: when a pane is
|
|
threaded in and the pane-scoped resolver yields a port, ``StartCascade``
|
|
targets THAT port (61000) even though a lower foreign candidate (52548)
|
|
exists. Exercises the REAL ``resolve_cold_start_agy_rpc_port`` dispatch by
|
|
stubbing its rpc-module seams.
|
|
"""
|
|
import omnigent.antigravity_native_rpc as rpc
|
|
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
bridge_dir = bridge_mod.bridge_dir_for_bridge_id("bridge_cs_pane")
|
|
_seed_bridge_state(bridge_dir, "agy_conv_placeholder")
|
|
|
|
# The pane scopes to a higher agy's port (state 1); a lower FOREIGN candidate
|
|
# exists but must be ignored.
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"resolve_pane_agy_rpc_port_state",
|
|
lambda _sock, _tgt: rpc.PaneAgyResolution(agy_found=True, port=61000),
|
|
)
|
|
monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", lambda: [52548, 61000])
|
|
started: list[tuple[int, str]] = []
|
|
monkeypatch.setattr(_mod, "start_cascade", lambda port, cid: started.append((port, cid)))
|
|
|
|
def _ok(_request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={})
|
|
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def _mock_async_client(**kwargs: object) -> httpx.AsyncClient:
|
|
kwargs.pop("transport", None)
|
|
return real_async_client(transport=httpx.MockTransport(_ok), **kwargs)
|
|
|
|
monkeypatch.setattr(_mod.httpx, "AsyncClient", _mock_async_client)
|
|
|
|
await _mod._cold_start_agy_conversation(
|
|
bridge_dir,
|
|
"conv_cs",
|
|
base_url="http://test",
|
|
headers={},
|
|
tmux_socket=tmp_path / "agy.sock",
|
|
tmux_target="main",
|
|
timeout_s=1.0,
|
|
)
|
|
|
|
# StartCascade fired on the PANE-SCOPED port, NOT candidates[0] (52548).
|
|
assert len(started) == 1
|
|
assert started[0][0] == 61000
|
|
|
|
|
|
async def test_cli_cold_start_falls_back_when_no_pane(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""
|
|
No local pane (``tmux_socket``/``tmux_target`` is ``None``) → lowest candidate.
|
|
|
|
Preserves the current CLI behavior for the WebSocket-attach / remote-runner
|
|
path that has no local tmux socket: the cold-start falls back to
|
|
``_candidate_agy_rpc_ports()[0]`` and never consults the pane-scoped resolver.
|
|
"""
|
|
import omnigent.antigravity_native_rpc as rpc
|
|
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
bridge_dir = bridge_mod.bridge_dir_for_bridge_id("bridge_cs_nopane")
|
|
_seed_bridge_state(bridge_dir, "agy_conv_placeholder")
|
|
|
|
def _no_pane_scope(_sock: object, _tgt: object) -> rpc.PaneAgyResolution:
|
|
raise AssertionError("no pane → the pane-scoped resolver must not be consulted")
|
|
|
|
monkeypatch.setattr(rpc, "resolve_pane_agy_rpc_port_state", _no_pane_scope)
|
|
monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", lambda: [52548, 61000])
|
|
started: list[tuple[int, str]] = []
|
|
monkeypatch.setattr(_mod, "start_cascade", lambda port, cid: started.append((port, cid)))
|
|
|
|
def _ok(_request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={})
|
|
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def _mock_async_client(**kwargs: object) -> httpx.AsyncClient:
|
|
kwargs.pop("transport", None)
|
|
return real_async_client(transport=httpx.MockTransport(_ok), **kwargs)
|
|
|
|
monkeypatch.setattr(_mod.httpx, "AsyncClient", _mock_async_client)
|
|
|
|
await _mod._cold_start_agy_conversation(
|
|
bridge_dir,
|
|
"conv_cs",
|
|
base_url="http://test",
|
|
headers={},
|
|
tmux_socket=None,
|
|
tmux_target=None,
|
|
timeout_s=1.0,
|
|
)
|
|
|
|
assert len(started) == 1
|
|
assert started[0][0] == 52548 # lowest candidate, pane resolver never called
|
|
|
|
|
|
async def test_cli_cold_start_waits_when_pane_agy_absent(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""
|
|
Pane present, our agy NOT up yet, FOREIGN candidate present → no StartCascade.
|
|
|
|
The CLI ``tmux_start_on_attach`` early-poll window the R2 review flagged: the
|
|
cold-start runs concurrently with the attach, so before agy is ``exec``-ed the
|
|
pane has no agy (``agy_found=False``). A foreign agy is the only candidate. The
|
|
cold-start MUST keep polling (and, at the collapsed deadline, leave the
|
|
placeholder) — NOT bind the foreign candidate.
|
|
"""
|
|
import omnigent.antigravity_native_rpc as rpc
|
|
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
bridge_dir = bridge_mod.bridge_dir_for_bridge_id("bridge_cs_earlypoll")
|
|
_seed_bridge_state(bridge_dir, "agy_conv_placeholder")
|
|
|
|
# Our agy not in the pane yet; a FOREIGN agy is the only candidate.
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"resolve_pane_agy_rpc_port_state",
|
|
lambda _sock, _tgt: rpc.PaneAgyResolution(agy_found=False, port=None),
|
|
)
|
|
|
|
def _no_candidates() -> list[int]:
|
|
raise AssertionError("must NOT consult candidates while our agy is not up yet")
|
|
|
|
monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", _no_candidates)
|
|
started: list[tuple[int, str]] = []
|
|
monkeypatch.setattr(_mod, "start_cascade", lambda port, cid: started.append((port, cid)))
|
|
|
|
async def _no_sleep(_seconds: float) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(_mod, "_agy_cold_start_poll_sleep", _no_sleep)
|
|
|
|
def _fail_client(**_kwargs: object) -> httpx.AsyncClient:
|
|
raise AssertionError("no real id → no external_session_id PATCH")
|
|
|
|
monkeypatch.setattr(_mod.httpx, "AsyncClient", _fail_client)
|
|
|
|
await _mod._cold_start_agy_conversation(
|
|
bridge_dir,
|
|
"conv_cs",
|
|
base_url="http://test",
|
|
headers={},
|
|
tmux_socket=tmp_path / "agy.sock",
|
|
tmux_target="main",
|
|
timeout_s=0.0, # collapse the deadline so the keep-polling loop bails at once
|
|
)
|
|
|
|
# Never bound the foreign candidate; placeholder stands for the reader to bind.
|
|
assert started == []
|
|
after = read_bridge_state(bridge_dir)
|
|
assert after is not None
|
|
assert _mod.is_placeholder_conversation_id(after.conversation_id)
|
|
|
|
|
|
async def test_cli_cold_start_falls_back_when_port_unattributable(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
) -> None:
|
|
"""
|
|
Pane present, our agy found, port not lsof-attributable → candidate fallback.
|
|
|
|
Restricted-/proc one-agy-per-pod on the CLI path: agy IS up in the pane
|
|
(``agy_found=True``) but its port is not lsof-attributable, so the lone
|
|
candidate (ours) is used.
|
|
"""
|
|
import omnigent.antigravity_native_rpc as rpc
|
|
|
|
monkeypatch.setattr(bridge_mod, "_BRIDGE_ROOT", tmp_path / "antigravity-native")
|
|
bridge_dir = bridge_mod.bridge_dir_for_bridge_id("bridge_cs_noport")
|
|
_seed_bridge_state(bridge_dir, "agy_conv_placeholder")
|
|
|
|
monkeypatch.setattr(
|
|
rpc,
|
|
"resolve_pane_agy_rpc_port_state",
|
|
lambda _sock, _tgt: rpc.PaneAgyResolution(agy_found=True, port=None),
|
|
)
|
|
monkeypatch.setattr(rpc, "_candidate_agy_rpc_ports", lambda: [52548])
|
|
started: list[tuple[int, str]] = []
|
|
monkeypatch.setattr(_mod, "start_cascade", lambda port, cid: started.append((port, cid)))
|
|
|
|
def _ok(_request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, json={})
|
|
|
|
real_async_client = httpx.AsyncClient
|
|
|
|
def _mock_async_client(**kwargs: object) -> httpx.AsyncClient:
|
|
kwargs.pop("transport", None)
|
|
return real_async_client(transport=httpx.MockTransport(_ok), **kwargs)
|
|
|
|
monkeypatch.setattr(_mod.httpx, "AsyncClient", _mock_async_client)
|
|
|
|
await _mod._cold_start_agy_conversation(
|
|
bridge_dir,
|
|
"conv_cs",
|
|
base_url="http://test",
|
|
headers={},
|
|
tmux_socket=tmp_path / "agy.sock",
|
|
tmux_target="main",
|
|
timeout_s=1.0,
|
|
)
|
|
|
|
assert len(started) == 1
|
|
assert started[0][0] == 52548 # safe candidate fallback (agy is up in the pane)
|